Configuring Flutter apps using --dart-define-from-file

Configuring Flutter apps using --dart-define-from-file

Introduction

As a cross-platform solution, Flutter makes our lives more manageable again and improves development. Each new version improves and offers more and more possibilities for developers. The development of Flutter goes hand in hand with the development of the Dart language. 

Starting from version 3.7, Dart introduces several new features that facilitate application configuration management. Among the most significant are:

These functionalities empower developers to manage better environment variables, like API keys, database credentials, sensitive data, and application configuration. These can be particularly useful when building multi-layered applications or tailoring an application to various environments (flavoring) like production, testing, or development.

white labeling

White labeling

The solution presented below can also be great for white-label apps. To explain what a white label app is, from a perspective of CI/CD and what Codemagic’s documentation provides:

According to Codemagic:white labeling” refers to automating the process of rebranding your core app for each customer and then publishing the app to stores or other distribution channels. A white labeling pipeline will run scripts to change colors, logos, images, and fonts and update other app settings such as bundle identifiers, provisioning profiles, certificates, API endpoints, and other configuration settings unique to each customer.

From a product (app) perspective, “white labeling” can be treated as configuring the app's branding rather than hardcoding to the app's code. JSON file-based configuration addresses most white labeling issues. This setup is undoubtedly an essential step towards making this process possible.

The most significant advantages of --dart-define-from-file

Let's imagine that we have various secrets and configurations necessary for white labeling scattered throughout our codebase (e.g. resource values in android/app/src/prod/res/values/string.xml), and the developers have to manage it and remember the location of every piece of data. That’d be nearly impossible to keep intact and likely lead to errors as the app grows. Flutter projects often depend on several external dependencies, including backend systems and 3rd-party services configured on the Dart and native side of the codebase.

First, to avoid publishing each flavor's configuration with each app release, we must keep the configuration out of the codebase. It is also quite dangerous because we can accidentally introduce something undesirable into public view. Adding appropriate declarations in .gitignore may not work in such a case. Introducing something undesirable is especially easy for a new developer to join the team and be unaware of the entire project. Furthermore, she or he may have trouble finding something.

Secondly, such a scattering of the important ones makes managing it very difficult. Thanks to --dart-define-from-file and JSON configuration files, we have everything in one place. We can easily and quickly change this data depending on the environment. This is a highly convenient solution for flavoring apps.

Moreover, configuring such JSON files in our Flutter project allows us to access these variables on the native side easily without additional configuration and parsing, which is another great advantage. Flutter, as a cross-platform solution, allows us now to touch the native side here easily without extra effort. Not every Flutter dev has experience with native languages (it's worth having such knowledge :D), so any such facility is valuable.

How does it work for Flutter?

This mechanism lets developers define environment compile-time variables and load configuration data from JSON files. As a result, it becomes possible to tailor the application flexibly to various environments and easily alter its behavior without the need to modify the source code.

Previous solution with --dart-define parameter

Previously, to pass multiple environment variables, we had to use the --dart-define parameter and pass a sequence of these variables.

--dart-define=var1=value1
--dart-define=var2=value2
--dart-define=var3=value3

--dart-define-from-file

We currently use one JSON file for a specific environment, and we have access to all variables simultaneously. This extremely convenient solution can save us time when, for example, we implement various scripts in Makefile.

--dart-define-from-file=keys_staging.json

keys_staging.json

{
...
  "MERCHANT_ID": "merchant_id",
  "TERMS_CONDITIONS_PATH": "/terms",
  "PRIVACY_POLICY_PATH": "/privacy-policy",
...
}

Thanks to this solution, CD becomes much simpler. If, for example, we use VS Code, we can also trivially add the appropriate configuration in the launch.json file.

{
  "configurations": [
    {
      "name": "Debug Prod",
      "type": "dart",
      "request": "launch",
      "program": "lib/main.dart",
      "args": [
        "--flavor",
        "prod",
        "--dart-define-from-file",
        "keys_production.json"
      ]
    },
   {
      "name": "Debug Staging",
      "type": "dart",
      "request": "launch",
      "program": "lib/main.dart",
      "args": [
        "--flavor",
        "staging",
        "--dart-define-from-file",
        "keys_staging.json"
      ]
    },
  ]
}

Accessing environment declarations

When seeking to access specified environment declaration values, it's advisable to employ one of the fromEnvironment constructors within a constant context for optimal efficiency. For boolean values, utilize bool.fromEnvironment; for integers, opt for int.fromEnvironment; and for other types of values, such as strings, rely on String.fromEnvironment. This approach ensures streamlined access and clarity in handling various environment declarations. It's worth remembering that environment declaration constructors only work when called as const. Most compilers need to be able to evaluate their value at compile time.

String get merchantId =>
      const String.fromEnvironment('MERCHANT_ID');

Each fromEnvironment constructor necessitates the inclusion of the name or key corresponding to the environment declaration. Additionally, it accommodates an optional named argument, defaultValue, which enables the override of the default fallback value. This fallback value is employed when the declaration is undefined or the specified value cannot be parsed as the expected type. This comprehensive approach ensures robust handling of environment declarations, providing flexibility and clarity in configuration management.

Understanding the native side

iOS

To understand how iOS deals with env variables, we need to cite the definition of xcconfig format files.

A Configuration Settings File (a file with a .xcconfig file extension), also known as a build configuration file or xcconfig file, is a plain text file that defines and overrides the build settings for a particular build configuration of a project or target. This type of file can be edited outside of Xcode and integrates well with source control systems. Build configuration files adhere to specific formatting rules, and produce build warnings if they do not.

The Generated.xcconfig file in a Flutter iOS project is an automatically generated configuration file created during the project compilation for the iOS platform. It's part of building an iOS project in the Flutter environment. Depending on changes in the project or configuration, this file may be regenerated to reflect those changes. You can read more about this topic here.

generated.xcconfig

When you enter the realm of the project editor's "Build Settings" tab, you're met with an extensive array of configuration options scattered across projects, targets, and configurations. Fortunately, there is a more efficient method for handling this labyrinthine configuration that doesn't involve navigating through a maze of tabs and disclosure arrows. Xcode build configuration files, often referred to by their .xcconfig extension, offer a means to declare and manage build settings for your application independently of Xcode.

In essence, each configuration file comprises a series of key-value assignments following this syntax:

MERCHANT_ID=merchantId

All environmental variables defined in the JSON files are similarly mirrored within this file.


Limitations

Xcconfig files interpret the sequence // as a comment delimiter, irrespective of whether it's enclosed in quotation marks. The easiest thing to do when we want to pass a specific path in our env variable is to simply exclude the scheme and prefix URLs with https:// in our code instead.

Interesting case

Let's assume we need access to our env variable in AppDelegate.swift. We also know that build settings defined by the Xcode project file, xcconfig files, and environment variables are only available at build time. The solution to this problem is to use the Info.plist file. Info.plist file is compiled according to the build settings provided and copied into the resulting app bundle. Therefore, by adding references to specific env variables, e.g. $(MERCHANT_ID), we can access the values for those settings through the infoDictionary property of Foundation’s Bundle API.

Info.plist

<key>MerchantId</key>
<string>$(MERCHANT_ID)</string>

AppDelegate.swift

guard let brazeApiKey = Bundle.main.object(forInfoDictionaryKey: "MerchantId") as? String
else {
   throw NSError(
     domain: "Merchant id error", code: 1,
     userInfo: ["reason": "Problem with getting merchant id"])
}

Android

Let's take a closer look at the Android platform. Flutter 3.7 introduced a fix to pass the env variable set in the JSON file to build.gradle. Great, isn't it? This fix is part of the Flutter build_info.test file in the flutter_tools folder.

Suppose we want to manage the deep link scheme from configuration files. This may be needed when, for example, we create a white app - different brands, so the deep links should be different. The deep link scheme is set in AndroidManifest.xml using the current configuration and proper string resource. To use our env variable, create a new resValue in build.gradle. The resource created this way is not limited to AndroidMainfest.

AndroidMainfest.xml

 <intent-filter
    android:autoVerify="true">
    <action
       android:name="android.intent.action.VIEW"/>
    <category
       android:name="android.intent.category.DEFAULT"/>
    <category
       android:name="android.intent.category.BROWSABLE"/>
    <data
       android:scheme="@string/deeplink_scheme"
       android:host="@string/deeplink_host"/>
​​ </intent-filter>

android/app/build.gradle

defaultConfig {
...
resValue "string", "deeplinkScheme", DEEPLINK_SCHEME
...
}

android/app/src/prod/res/values/string.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
...
    <string name="deeplink_scheme">@string/deeplinkScheme</string>
    <string name="deeplink_host">@string/deeplinkHost</string>
...
</resources>

The resValue value from the build.gradle file is passed to the Android application as a resource at compile time. Gradle processes these values during compilation and includes them in the Android app's resource files (such as strings.xml).

Conclusion

--dart-define-from-file parameter and enhancements in JSON file handling in Flutter 3.7 bring new possibilities for developers in managing application configuration. These features make it easier to customize an application for different environments while maintaining code transparency and flexibility. Users can leverage them when building Flutter applications to increase scalability and ease of configuration.

Please note that there are other ways to deal with the problem of configuring our app. There are other ways to use the environment variables mechanism: flutter_dotenv, envied. However, this article focuses on solving this problem using JSON files, because this method works great when we have several flavors and want to manage our app from one place better. If someone is planning to create what can be called a white app, this method will certainly help you deal with the basic problems. We have greater control over all data, making working in a larger team easier. Automation becomes easier with this approach, and code changes are usually unnecessary. We also keep the configuration, which may be hidden somewhere in folders for Android and iOS. All you need to do is make appropriate changes to JSON files.

I didn’t touch on the issue of safety in the article. Honestly, none of the available front-end methods are completely secure. Remember to use --obfuscate when building your app. Additional data encryption can also increase security. Unfortunately, to put it mildly, there is no 100% certainty that some data will not be caught during reverse engineering. The worst thing you can do is deliver hard-coded secrets somewhere in the codebase.

Michał Kochmański avatar
Michał Kochmański
Flutter developer