App Update Feature Flag with Firebase Remote Config

Upgrade User Experience by Managing App Updates Through Firebase Remote Config

·

9 min read

My client required a Force App Update feature in a Flutter application but preferred not to involve any server-side feature flags or backend support. This is where Firebase Remote Config becomes valuable.

Firebase Remote Config acts as a feature flag management system within your app that does not require traditional backend infrastructure. It enables dynamic control over feature flags, allowing you to modify app behaviour & appearance directly from the Firebase console.

With Remote Config, you can turn features on or off in real time, & the app can respond to these changes either statically (at launch) or dynamically (during runtime), making it an efficient solution for managing app features & configurations.


Firebase Remote Config provides a set of key-value pairs that are ideal for implementing an app update feature. You can define keys according to your specific requirements. For this use case, the following keys are used, which are self-explanatory:

ios_appstore_version

android_playstore_version

ios_appstore_url

android_playstore_url


To begin, create a Firebase project in the Firebase Console. If you're unfamiliar with the setup process, you can follow the official Firebase documentation to get started.

Once your project is created, navigate to it in the Firebase Console. From the left-hand menu, expand the Run section & select Remote Config. You should see a configuration interface similar to the one below:

Since this is the initial configuration, select the Client option & click on Create Configuration. You should now see a screen similar to the following.

In the Parameter Name (Key) field, enter app_update. Set the Data Type to JSON, & expand the Default Value section to open the JSON editor. Add the necessary keys & values, then save your configuration.

After drafting the configuration changes, you will need to publish them as indicated.

Configuration on the Firebase Console is now complete.


Now, let's move on to the most exciting part—writing the actual code to respond to these changes. For my requirements, I am satisfied with my app reacting to the changes statically, meaning at app launch.

The Flutter app consist of following types: AppUpdateFlag, FeatureFlag, RemoteConfigHandler, AppUpdateView, InitialView Here UI will react to changes published events using StreamBuilder widget.

The AppUpdateFlag class manages app update details, including version numbers & URLs for Android & iOS. It includes:

  • Fields:

    • iOSAppStoreVersion, androidPlayStoreVersion: Stores the latest app versions.

    • iOSAppStoreURL, androidPlayStoreURL: Provides update URLs.

    • shouldShowUpdate: Indicates if an update prompt is needed.

  • Methods:

    • validate(): Compares the current app version with the store version. If the app version is outdated, it sets shouldShowUpdate to true.

    • performUpdate(): Opens the app store URL based on the platform (Android or iOS) to facilitate the update.

This class helps determine if users need to update their app & guides them to the appropriate store for the update.

AppUpdateFlag class

/// Represents the app update flag, including version & URL information for both Android & iOS.
class AppUpdateFlag {
  static const appUpdateKey = "app_update";
  static const iOSVersionKey = "ios_appstore_version";
  static const androidVersionKey = "android_playstore_version";
  static const iOSURLKey = "ios_appstore_url";
  static const androidURLKey = "android_playstore_url";

  String iOSAppStoreVersion;
  String androidPlayStoreVersion;
  String iOSAppStoreURL;
  String androidPlayStoreURL;
  bool shouldShowUpdate =
      false; // Flag to indicate whether the update prompt should be shown

  AppUpdateFlag({
    required this.iOSAppStoreVersion,
    required this.androidPlayStoreVersion,
    required this.iOSAppStoreURL,
    required this.androidPlayStoreURL,
  });

  /// Creates an AppUpdateFlag instance from JSON.
  factory AppUpdateFlag.fromJson(Map<String, dynamic> json) {
    return AppUpdateFlag(
      iOSAppStoreVersion: json[iOSVersionKey],
      androidPlayStoreVersion: json[androidVersionKey],
      iOSAppStoreURL: json[iOSURLKey],
      androidPlayStoreURL: json[androidURLKey],
    );
  }

  /// Validates the app update version & determines if an update is required.
  void validate() async {
    try {
      PackageInfo packageInfo = await PackageInfo.fromPlatform();
      final clientAppVersion = packageInfo.version; // Current app version
      String? storeVersion;

      if (Platform.isAndroid) {
        storeVersion =
            androidPlayStoreVersion; // Get the Play Store version for Android
      } else if (Platform.isIOS) {
        storeVersion = iOSAppStoreVersion; // Get the App Store version for iOS
      }
      if (storeVersion != null) {
        // Compare the client version with the store version
        final value = clientAppVersion.compareTo(storeVersion) == -1;
        shouldShowUpdate = value; // Set flag if an update is required
      }
    } catch (e) {
      if (kDebugMode) {
        print("Error validating app version: ${e.toString()}");
      }
    }
  }

  /// Opens the appropriate app store URL to perform the update.
  Future<void> performUpdate() async {
    try {
      String? urlString;

      if (Platform.isAndroid) {
        urlString = androidPlayStoreURL; // Get Play Store URL for Android
      } else if (Platform.isIOS) {
        urlString = iOSAppStoreURL; // Get App Store URL for iOS
      }

      if (urlString != null) {
        final Uri url = Uri.parse(urlString);
        final launched = await launchUrl(
          url,
          mode:
              LaunchMode.externalApplication, // Launch the app store externally
        );
        if (!launched) {
          throw Exception('Could not launch $url'); // Handle failure to launch
        }
      }
    } catch (e) {
      if (kDebugMode) {
        print("Error performing app update: ${e.toString()}");
      }
    }
  }
}

FeatureFlag class

The FeatureFlag class manages feature flags within the app, particularly focusing on the app update flag.

  • Properties:

    • _appUpdateFlagSubject: A BehaviorSubject for handling streams of AppUpdateFlag, allowing listeners to react to updates in real-time.

    • appUpdateFlag: Holds the current instance of the app update flag.

  • Methods:

    • fromJson(): Constructs a FeatureFlag instance from a JSON object, extracting the AppUpdateFlag details.

    • publish(): Adds a new AppUpdateFlag to the stream, notifying all listeners of the update.

This class is essential for dynamically managing & responding to changes in feature flags, especially for app updates.

/// Manages feature flags within the app, such as the app update flag.
class FeatureFlag {
  final _appUpdateFlagSubject =
      BehaviorSubject<AppUpdateFlag>(); // Stream controller for app update flag
  Stream<AppUpdateFlag> get appUpdateStream => _appUpdateFlagSubject
      .stream; // Stream for listening to app update flag changes

  AppUpdateFlag? appUpdateFlag; // The current app update flag

  FeatureFlag({
    this.appUpdateFlag,
  });

  /// Creates a FeatureFlag instance from JSON.
  factory FeatureFlag.fromJson(Map<String, dynamic> json) {
    return FeatureFlag(
      appUpdateFlag: AppUpdateFlag.fromJson(json[AppUpdateFlag.appUpdateKey]),
    );
  }

  /// Publishes the new app update flag to the stream.
  void publish(AppUpdateFlag appUpdateFlag) =>
      _appUpdateFlagSubject.add(appUpdateFlag);
}

RemoteConfigHandler Class

The RemoteConfigHandler class manages Firebase Remote Config operations, including fetching, activating, & reacting to configuration changes.

  • Properties:

    • featureFlag: An instance of FeatureFlag used to manage & update feature flags.

    • _remoteConfig: The instance of FirebaseRemoteConfig used for interacting with Firebase Remote Config.

  • Singleton:

    • instance: Provides a singleton instance of RemoteConfigHandler to ensure a single point of configuration management.
  • Methods:

    • configure(): Fetches & activates the latest remote configuration values. It also handles app updates based on the fetched configuration & listens for real-time updates.

    • fetchAppUpdateRemoteConfig(): Retrieves the remote configuration value specifically for app update information.

    • handleAppUpdate(RemoteConfigValue appUpdateValue): Parses the JSON data from the app update configuration, validates it, creates an AppUpdateFlag object, and updates the feature flag accordingly.

This class is crucial for setting up Firebase Remote Config, managing feature flags, & ensuring the app can react to configuration changes dynamically.

/// Handles the Remote Config operations such as fetching, activating, & reacting to changes.
class RemoteConfigHandler {
  FeatureFlag featureFlag = FeatureFlag(); // Instance to handle feature flags
  final _remoteConfig =
      FirebaseRemoteConfig.instance; // Firebase Remote Config instance

  // Singleton instance of RemoteConfigHandler
  static final RemoteConfigHandler instance = RemoteConfigHandler._internal();

  // Factory constructor returns the singleton instance
  factory RemoteConfigHandler() {
    return instance;
  }

  // Private internal constructor
  RemoteConfigHandler._internal();

  /// Configures Firebase Remote Config by fetching & activating the latest values.
  /// Also listens for any config updates in real-time.
  void configure() async {
    try {
      // Fetch the latest config & activate it
      await _remoteConfig.fetch();
      await _remoteConfig.activate();

      // Fetch the app update config & handle the app update logic
      final config = fetchAppUpdateRemoteConfig();
      handleAppUpdate(config);
    } catch (e) {
      // Handle any errors that occur during fetching
      print("Error fetching remote config: $e");
    }
  }

  /// Fetches the remote config value for app update information.
  RemoteConfigValue fetchAppUpdateRemoteConfig() {
    final appUpdateValue = _remoteConfig.getValue(AppUpdateFlag.appUpdateKey);
    return appUpdateValue;
  }

  /// Handles the logic related to app update based on the fetched remote config value.
  void handleAppUpdate(RemoteConfigValue appUpdateValue) {
    try {
      // Parse the app update config JSON data
      Map<String, dynamic> jsonData = jsonDecode(appUpdateValue.asString());

      // Validate mandatory fields
      Utils.validateMandatory(
        fields: [
          AppUpdateFlag.androidVersionKey,
          AppUpdateFlag.iOSVersionKey,
          AppUpdateFlag.iOSURLKey,
          AppUpdateFlag.androidURLKey,
        ],
        inJson: jsonData,
      );

      // Create an AppUpdateFlag object from JSON
      AppUpdateFlag appUpdateFlag = AppUpdateFlag.fromJson(jsonData);
      appUpdateFlag.validate();

      // Update the feature flag with the new app update flag & publish it
      featureFlag.appUpdateFlag = appUpdateFlag;
      featureFlag.publish(appUpdateFlag);
    } catch (e) {
      if (kDebugMode) {
        print("Error handling app update: ${e.toString()}");
      }
    }
  }
}

AppUpdateView Class

The AppUpdateView class is a StatelessWidget designed to present an update prompt dialog to users when an app update is available.

  • Constructor:

    • AppUpdateView({Key? key}): Initializes the widget with an optional key.
  • Methods:

    • build(BuildContext context): The main method that builds the widget. It schedules a callback to display the update dialog right after the current frame is rendered. This ensures that the dialog is shown only after the widget is fully built. The widget itself returns a Container with a white background.

    • _showUpdateDialog(BuildContext context): Displays an AlertDialog with a title and message notifying users of the available update. The dialog includes a single button labeled "Update Now". When pressed, it checks if the appUpdateFlag is not null & calls the performUpdate() method from the AppUpdateFlag class to handle the update process.

This widget is used to prompt users to update the app when necessary, ensuring they have the latest version by directing them to the app store for the update.

/// A stateless widget that displays an update prompt dialog when the app requires an update.
class AppUpdateView extends StatelessWidget {
  const AppUpdateView({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // Schedules a callback to show the update dialog after the current frame is rendered.
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _showUpdateDialog(context);
    });

    // Returns an empty container with a white background.
    return Container(color: AppColors.white);
  }

  /// Displays a dialog prompting the user to update the app.
  void _showUpdateDialog(BuildContext context) {
    showDialog(
      context: context,
      builder: (context) {
        return AlertDialog(
          title: const Text('Update Available'), // Title of the dialog
          content: const Text(
              'New update available. Please update now.'), // Message prompting the update
          actions: [
            TextButton(
              onPressed: () {
                // Calls the performUpdate method if the appUpdateFlag is not null
                final updateFlag =
                    RemoteConfigHandler.instance.featureFlag.appUpdateFlag;
                if (updateFlag != null) {
                  updateFlag.performUpdate();
                }
              },
              child: const Text(
                  'Update Now'), // Text for the button that initiates the update
            ),
          ],
        );
      },
    );
  }
}

InitialView class

The InitialView widget combines streams from sign-in status & app update flags to determine which view to display. It shows an update prompt if an update is available, or directs the user to either the home screen or sign-in screen based on their authentication status. If no data is available, it displays a splash screen.

/// A stateless widget that displays different views based on the user's sign-in status
/// and whether an app update is available.
class InitialView extends StatelessWidget {
  const InitialView({super.key});

  @override
  Widget build(BuildContext context) {
    // Retrieves the sign-in state from the BlocProvider
    final signInState = BlocProviderWidget.of(context)?.state.signInBloc;

    // Retrieves the stream of app update flags
    final appUpdateStream =
        RemoteConfigHandler.instance.featureFlag.appUpdateStream;

    // Retrieves the stream of sign-in status
    final stream1 = signInState?.stateStream;

    // Combines the sign-in status & app update flag streams into a single stream
    final combinedStream = Rx.combineLatest2<SignInStatus, AppUpdateFlag,
        Tuple2<SignInStatus, AppUpdateFlag>>(
      stream1 ?? const Stream.empty(), // Default to an empty stream if null
      appUpdateStream,
      (signInStatus, appUpdateFlag) => Tuple2(signInStatus, appUpdateFlag),
    );

    // Builds the widget based on the combined stream's data
    return StreamBuilder<Tuple2<SignInStatus, AppUpdateFlag>>(
      stream: combinedStream,
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          final signInStatus = snapshot.data!.item1;
          final appUpdateFlag = snapshot.data!.item2;

          // Checks if an update is required & shows the appropriate view
          if (appUpdateFlag.shouldShowUpdate) {
            return const AppUpdateView();
          } else {
            // Shows the HomeLandingView if signed in, otherwise SignInView
            return signInStatus == SignInStatus.signedIn
                ? const HomeLandingView()
                : const SignInView();
          }
        } else {
          // Shows the SplashView if no data is available
          return const SplashView();
        }
      },
    );
  }
}

Attached is a screenshot demonstrating the functionality.