The Right Way to Set Environment Variables with Compile-Time Variables

Samuel Abada
ITNEXT
Published in
7 min readApr 9, 2023

--

Environment variables at compile time

When building an app, we often use environment variables for one thing or another. These environment variables could be API keys, base URLs, keys and passphrases and whatever data we don't want to expose directly in the app. These environment variables could be strings, integers or even boolean.

Developers have various means of treating such variables, with some preferring to keep these variables in some .env file and reading the file and getting the variables in the file and others keeping these variables in some dart file and ignoring it (adding to .gitignore). Ultimately, these approaches have problems, as external dependencies are required for .env files. Depending on the dependency, the file becomes part of the app asset or a dart file is generated by the package/dependency. It should be noted that certain things might be deemed “too secret”, so you might want to handle them from your backend instead of handling them on the client side.

Compile time variables

They offer a way to provide environment variables only at compile time. This implies that the variables are defined at the point of doing flutter run or flutter build. By implication, this command can be added as part of the compile/build process for a CI environment. Compile time environment variable declarations are not new to the dart and flutter ecosystem. There are two ways of adding compile-time variables.
- using the command — dart-define
- using the command — dart-define-from-file

Dart define

The command — dart-define offers a way of passing a single environment variable at compile time.

flutter run --dart-define=baseUrl=https://github.com/mastersam07

The above shows how to pass the environment variable baseUrl at compile time. The same command can repeatedly be used for multiple environment variables, as shown below.

flutter run --dart-define=baseUrl=https://github.com/mastersam07 --dart-define=apiKey=djvghdgfhsg6utytuytyut

However, the above could get messy, especially with adding numerous environment variables, as we could end up with a long command.

Dart define from file

The command — dart-define-from-file is relatively new and allows for adding multiple variables by passing the path of a JSON format file where flutter defines a constant global pool

flutter run --dart-define-from-file=config.json

With the flutter engine landing support for .env files out of the box, — dart-define-from-file allows for adding multiple variables by passing the path of a .env file where flutter defines a constant global pool

flutter run --dart-define-from-file=.env

config.json is a JSON file with key-value pairs in the format:

{
"BASE_URL": "https://github.com/mastersam07",
"API_KEY": "ftfghdf6r6546r76t6t75yt67u"
}

For envs, .env is a simple env file with key-value pairs in the format:

BASE_URL=https://github.com/mastersam07
API_KEY=ftfghdf6r6546r76t6t75yt67u

All compile-time variables can be accessed the same way, irrespective of your approach. They are all available as constants from the String.fromEnvironment, bool.fromEnvironment and int.fromEnvironment.

Superpowered environment variables

The sweet part of using — dart-define-from-file is that variables defined here can be accessed by gradle. You could check this PR and its related issue for more details.

Let's consider the following hypothetical situation:

We have built an app and want to set the suffix and app name from the compile time variable. We have a config.json as shown below:

{
"APP_NAME": "batcave",
"APP_SUFFIX": ".dev",
"MAPS_API_KEY": "someKeyString"
}

For env files, we have a .env file as shown below:

APP_NAME=batcave
APP_SUFFIX=.dev
MAPS_API_KEY=someKeyString

No extra configuration is needed natively to make this work. Using the above is as simple as running: flutter run — dart-define-from-file=config.json or flutter run — dart-define-from-file=.env as the case may be and make the following change to our app-level build.gradle:

defaultConfig {
applicationId "tech.mastersam.kaunta"
applicationIdSuffix APP_SUFFIX
....
resValue "string", "app_name", APP_NAME
resValue "string", "googleMapApiKey", MAPS_API_KEY
}

After creating the string resource, we must reference the APP_NAME and MAPS_API_KEY in our Android manifest. We do this by adding the following snippet to the application tag in your AndroidManifest.xml.

android:label="@string/app_name"
<meta-data android:name="com.google.android.geo.API_KEY"
android:value="@string/googleMapApiKey"/>

Great. We have passed variables down to the native side of things at compile time. However, our Android build would fail with errors when we fail to provide the necessary compile-time variables. To mitigate this, we have to provide default values. In your app-level build.gradle, do the following:

def envVariables = [
APP_NAME: project.hasProperty('APP_NAME')
? APP_NAME
: 'batcave',
APP_SUFFIX: project.hasProperty('APP_SUFFIX')
? APP_SUFFIX
: null,
MAPS_API_KEY: project.hasProperty('MAPS_API_KEY')
? MAPS_API_KEY
: "someString",
];

Using the compile time variables, we end up with the below, and everything remains the same.

defaultConfig {
applicationId "tech.mastersam.kaunta"
applicationIdSuffix envVariables.APP_SUFFIX
....
resValue "string", "app_name", envVariables.APP_NAME
resValue "string", "googleMapApiKey", envVariables.MAPS_API_KEY
}

How about ios? We just described what passing compile time variables feels like on the Android side. Can we do the same on ios? Absolutely.

The ios part of things is similar to the Android side of things. You reference the environment variable from where it's needed. In the example above, I have defined the app name and suffix. Let's see how we use these in our app.

info.plist
info tab xcodeproj

As seen above, you can make the edit in your info.plist or open your code project and make the edit there. We should also handle defaults for the compile-time variables to avoid build errors due to missing compile-time variables. To do this, we create the xcconfig file, register it as part of the debug, and release xcconfig files. Under the hood, the engine generates the file Generated.xcconfig, which holds specific values, including compile time variables and registers it as part of the debug and release xcconfig file. We are adopting a similar approach for the defaults. The content of the Default.xcconfig is shown below:

APP_NAME=Batcave
APP_SUFFIX=.dev
MAPS_API_KEY=someKey

Register the default xcconfig as part of the release and debug xcconfig is as simple as importing/including the file just before the import of the generated xcconfig.

#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"
#include "Default.xcconfig"
#include "Generated.xcconfig"

The above is a sample Release.xcconfig file. I have included the Default.xcconfig file here. You should do similar for the Debug.xcconfig file.

The xcconfig files treat // as a delimiter and ignore everything that comes after it. The implication here is that you cannot set values like https://github.com for an environment variable as only https would be recorded and the rest ignored. You could pass the URL without the https:// and concatenate the compile time variable with the https:// where needed or follow the walkaround mentioned here.

In addition, you could include these commands in the launch configurations of your ide.

For users of vs code, here is a sample launch.json

{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "batcave",
"request": "launch",
"type": "dart",
"toolArgs": [
"--dart-define-from-file",
"config.json", // for json files
".env" // for env files
],
}
]
}

For users of android studio, you will have to edit the configuration(workspace) as shown below:

Android studio edit configuration

You can also add these commands as part of our ci run/build process, irrespective of the ci platform you are using, as these commands are baked into the Flutter SDK.

In conclusion, compile-time variables can be accessed in all layers of your application from the dart side to the native side(android and ios) hence opening a new world of possibilities like flavors but with compile-time variables which is outside the scope of this article but perhaps some other time.

Update(August 2023)

From flutter 3.13, there is native support for .env files. You could read the PR which landed it here. This article has been edited to reflect the changes. This updates brings a couple of goodies:

  • Eliminates the need for third party dependencies in reading .env files.
  • .env files could be used directly to hold environment variables.
  • Support for multiple .env files.
  • .env files can be used alongside json files to inject environment variables at runtime.
  • Values defined in .env files can be access natively from the android and ios side as described earlier in the “Superpowered environment variables” section of this article.

If you have any questions or comments about this article, please do not hesitate to contact me on Twitter at mastersam_, on LinkedIn at Samuel Abada or on Github Mastersam07.

Resources

--

--

Google Developer Expert Flutter & Dart | Mobile Engineer | Technical Writer | Speaker