App Update Feature Flag with Firebase Remote Config
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 setsshouldShowUpdate
totrue
.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
: ABehaviorSubject
for handling streams ofAppUpdateFlag
, allowing listeners to react to updates in real-time.appUpdateFlag
: Holds the current instance of the app update flag.
Methods:
fromJson()
: Constructs aFeatureFlag
instance from a JSON object, extracting theAppUpdateFlag
details.publish()
: Adds a newAppUpdateFlag
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 ofFeatureFlag
used to manage & update feature flags._remoteConfig
: The instance ofFirebaseRemoteConfig
used for interacting with Firebase Remote Config.
Singleton:
instance
: Provides a singleton instance ofRemoteConfigHandler
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 anAppUpdateFlag
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 aContainer
with a white background._showUpdateDialog(BuildContext context)
: Displays anAlertDialog
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 theappUpdateFlag
is not null & calls theperformUpdate()
method from theAppUpdateFlag
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.
Subscribe to my newsletter
Read articles from Nasir directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Nasir
Nasir
App developer in iOS & Flutter