Handle environment variables in Flutter | LLF #8

nombrekeff

Keff

Posted on June 29, 2022

Handle environment variables in Flutter | LLF #8

Hey there! 👋 I'm back at it again with another little Flutter post.

In this case, I will take you through how to set up and use environment variables inside your Flutter apps to keep your secret data safe. I will show a couple of solutions, one without any external packages, and one using the dotenv package. I will also show you how to handle different environments (e.g. stage, sandbox, prod, etc...)

Table of Contents


But first, let's take a look at the problem we're trying to solve:

The problem

You might already know that apps usually communicate with some sort of API/service/server via HTTPS. These systems usually have some sort of authentication in place to verify that calls and requests come from a trusted place. When using OAuth for example this is done by making the client send a couple of parameters to verify it's the app. These secret values are usually called "client_secret" and "client_key". But each system will have its own. You might also need to store other kinds of sensitive data you don't want to expose.

As you might already know these kinds of values should not be pushed to your VCS, so each developer working on the project must only have them stored locally.

So let's take a look at some of the solutions:


Solutions

Solution #1 (no dotenv)

There are a couple of solutions without using dotenv. One of them is to have an env.dart file and an env.dart.dist file, the env.dart file will never be pushed to the repo. Meanwhile, the env.dart.dist will be pushed but will contain empty or placeholder values.

Then when a developer clones the repo he/she will need to copy env.dart.dist to env.dart and fill in the correct environment variables.

This is how the env.dart would look like:



const clientKey = '044ecd1b740d2d3cf228786293b6669c57529080';
const clientSecret = '4981eb58b897c4e65f19396f1a372e6920800127';


Enter fullscreen mode Exit fullscreen mode

This is how the env.dart.dist would look like:



const clientSecret = '<client_secret>';
const clientId = '<client_id>';


Enter fullscreen mode Exit fullscreen mode

This is a fine solution and will do the job. But I would discourage this approach for a couple of reasons:

  • If you name the dist file like the one above, your IDE will not recognize the language as Dart so, no type checks.
  • If you instead name it like env.dist.dart, whenever you import let's say clientId in your app, the IDE will detect that value to be both in env.dart and env.dist.dart. This way you can make the mistake of importing the dist version instead of the real env.
  • Data/config is mixed with code, for me this is not a good idea. I prefer separating them to keep things clear.

Solution #2 (dotenv)

Now let's look at how to handle the environment with the dotenv package.

Dotenv allows us to load .env files inside our flutter apps. .env files are just a file that contains environment variables, something like this:



CLIENT_SECRET=4981eb58b897c4e65f19396f1a372e6920800127
CLIENT_ID=044ecd1b740d2d3cf228786293b6669c57529080


Enter fullscreen mode Exit fullscreen mode

Okay, now that we know what .env files are let's look at how to set up our project to work with them:

1. Create env files

This is quite straight forwards, create a .env file anywhere you want, just remember the path as we will need it further along. For this example I will create it in /env/.env, and will look like this:



CLIENT_SECRET=4981eb58b897c4e65f19396f1a372e6920800127
CLIENT_ID=044ecd1b740d2d3cf228786293b6669c57529080


Enter fullscreen mode Exit fullscreen mode

Then create /env/.env.dist (this will be the file that will be pushed to the VCS). It will look like this:



CLIENT_SECRET=  # Replace with the REAL client_secret
CLIENT_ID=      # Replace with the REAL client_id


Enter fullscreen mode Exit fullscreen mode

 2. Add .env to .gitignore

Before proceeding let's make sure we add the .env file to .gitignore to prevent pushing it by accident. Just add this (make sure it points to the file you created in step 1):



/env/*.env


Enter fullscreen mode Exit fullscreen mode

3. Add .env to pubspec.yml assets

For dotenv to be able to read the file, we must add it to the assets definition in pubspec.yml:



flutter:
  assets:
    - env/.env


Enter fullscreen mode Exit fullscreen mode

 4. Install the dotenv package:

Before we can use the files created above inside our flutter apps, we must first install the dotenv package. This is pretty straightforwards. You can install it via CLI:



flutter pub add dotenv


Enter fullscreen mode Exit fullscreen mode

Or add it to the pubspec.yml file:



dependencies:
  dotenv: ^4.0.1


Enter fullscreen mode Exit fullscreen mode

5. Create an Env class

This step is somewhat opinionated and could be done in a variety of ways, you could create consts instead of a class, use a provider, etc... Feel free to handle them in any way you like. I will just show you my approach

I will store this file in: src/lib/config/env.dart



import 'package:flutter_dotenv/flutter_dotenv.dart';

/// Interface for AppEnv. This will help us test our code. 
mixin IAppEnv {
  String clientSecret;
  String clientId;
}

class AppEnv implements IAppEnv {
  final String clientSecret = dotenv.get('CLIENT_SECRET', 'default-secret');
  final String clientId = dotenv.get('CLIENT_ID', 'default-id');
  final String name = 'pre'; // I will explain why I have this here further down
}

final appEnv = AppEnv();


Enter fullscreen mode Exit fullscreen mode

What this class does is load each environment variable from the .env file. dotenv.get allows us to pass in a fallback value in case the environment variable is not set.

We then instantiate AppEnv and assign it to appEnv, this is the variable we will use across our app whenever we need to access the environment.

6. Initialize dotenv

This is quite straight forwards, we must add the following to our main.dart main function, before we run our app:



Future<void> main() async {
  await dotenv.load(fileName: "env/.env"); 
  runApp(MyApp());
}


Enter fullscreen mode Exit fullscreen mode
  • Note that the fileName must match the path of our env file and must also be defined in the assets section of the pubspec.yml

After this we can peek into dotenv and see if it indeed has loaded what we expected:



Future<void> main() async {
  await dotenv.load(fileName: "env/.env");
  print('CLIENT_SECRET ${dotenv.get('CLIENT_SECRET')}'); 
  runApp(MyApp());
}


Enter fullscreen mode Exit fullscreen mode

If everything went well up until this point, we should see this printed to the console:
CLIENT_SECRET 4981eb58b897c4e65f19396f1a372e6920800127

7. We're all setup

After this point, we're all set up and ready to start using the env file inside the app. Let's look at an example using all of the stuff we've done:



class MyApiService extends Service {
  /// Use IAppEnv instead of AppEnv, this way we can inject custom envs for testing purposes
  final IAppEnv env; 

  MyApiService({required this.env});

  login(String username, String password) {
    return this.post(
      username, 
      password, 
      clientSecret: env.clientSecret, 
      clientId: env.clientId‚
    );
  }
}


Enter fullscreen mode Exit fullscreen mode

Handling multiple environments

This is cool and all, but what if our app can run in multiple environments? How do we handle this?

Well, it's quite straightforwards, we just need to create multiple .env files for each env (e.g. stage.env. prod.env, etc...) and then decide which one to load before building or running our app.

Flutter & dart don't currently have any kind of hooks we can use to run scripts/commands before an action, which is a shame and makes this a bit more convoluted. But it can be done nonetheless. What I mean is we can't "hook"/act whenever flutter runs or build our app, so we must create a custom script that does it for us.

But let's go step by step:

 1. Create files for each environment

I will just handle 2 environments in this example, but you can add as many as you'd like.

These are the files I've created:

  • env/stage.env
  • env/prod.env

They're exactly the same as .env for now, but we will change the values for each environment.

2. Replacing .env with the correct env

Now what we need to do is to replace the .env file with the one for the env we want to run our app against.

For example, if we want to run our app against stage, we must replace .env with pre.env. And the same goes for any other environment.

There are multiple ways of doing this:

  • you can do it by hand before launching/building your app (discouraged, as it is easy to forget)
  • you can use make or some similar build tool
  • you can use VSCode tasks

For this particular example, I will be using the VSCode tasks, as it's the way I currently handle this.

For this approach the first thing you need is a script that will handle the replacement of the file, here is what I use (scripts/replace-env.sh):



#!/bin/bash
envPath='env' # Path to the folder where all our .env files live
env=$1

if [ -z "$1" ]; then
    echo "No environment supplied, allowed: [stage, prod]"
    exit 1
fi

cp "$envPath/$env.env" "$envPath/.env" 
echo "Copied '$envPath/$env.env' to '$envPath/.env'"


Enter fullscreen mode Exit fullscreen mode
  • this script receives an env name e.g. stage, and tries to replace the current .env file with the one for the specified environment <env>.env.

Then we just need to call it like this:



$ ./replace-env.sh stage


Enter fullscreen mode Exit fullscreen mode

 3. Setting up VSCode tasks

For better ergonomics, I set up a couple of tasks and modify the launch configuration to handle this for us.

Let's start by defining the tasks (.vscode/tasks.json):



{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "replace-env-stage",
            "command": "./scripts/replace-env.sh",
            "args": ["stage"],
            "type": "shell"
        },
        {
            "label": "prepare-env-prod",
            "command": "./scripts/replace-env.sh",
            "args": ["prod"],
            "type": "shell"
        },
    ]
}


Enter fullscreen mode Exit fullscreen mode
  • Tasks can be run by pressing cmd + shift + p on mac or ctrl + shift + p on windows. Then searching for "Run task", click on the option, then it will show a list of tasks you can run, they should be "prepare-env-prod" and "replace-env-stage". Now you can decide which tasks you want to run.

The benefit of using tasks is that we can use them in conjunction with VSCode launch configurations. So the env is replaced when we run our app from VSCode.

 4. Modifying/creating launch config to run tasks

Now that we have the tasks created we can modify or create a launch configuration for each of the envs (.vscode/launch.json):



{
    "version": "0.2.0",
    "configurations": [
        {
            "preLaunchTask": "replace-env-stage",
            "name": "App (STAGE)",
            "request": "launch",
            "type": "dart"
        },
        {
            "preLaunchTask": "replace-env-prod",
            "name": "App (PROD)",
            "request": "launch",
            "type": "dart"
        },
    ]
}


Enter fullscreen mode Exit fullscreen mode

Now in the VSCode "Run & Debug" section, we can select the launch configuration we want to run our app with:

Image description

If you select the "App (STAGE)", the task "replace-env-stage" will be executed before running the app. The same goes for any other launch configuration.

5. Building app

Until now it's very useful for development, but if we want to build our app we must run the task or script before running flutter build. This can also be automated using tasks.

We just need to add a couple more tasks:



{
  "version": "0.2.0",
  "tasks": [
    // Previous tasks
    {
      "label": "build-stage-apk",
      "command": "flutter",
      "args": ["build", "apk"], 
      "type": "shell"
    },
    {
      "label": "build-prod-apk",
      "command": "flutter",
      "args": ["build", "apk", "--release"],
      "type": "shell"
    },

    {
      "label": "Build APK stage",
      "dependsOrder": "sequence",
      "dependsOn": ["replace-env-stage", "build-stage-apk", "open-apk-path"]
    },
    {
      "label": "Build APK prod",
      "dependsOrder": "sequence",
      "dependsOn": ["replace-env-prod", "build-prod-apk", "open-apk-path"]
    },

    {
        "label": "open-apk-path",
        "command": "open",
        "args": ["build/app/outputs/flutter-apk/"],
        "type": "shell"
    }
  ]
}


Enter fullscreen mode Exit fullscreen mode

Now instead of building our flutter app manually from the CLI, we can use the tasks we just created to do so. This way it's a lot harder to make mistakes and publish an app that points to an environment it's not supposed to.

Whenever we want to build an APK for prod we just need to open the command palette (cmd + shift + p), search for "Build APK prod" and run the task. This will:

  1. replace the environment "replace-env-prod"
  2. build the apk "build-prod-apk"
  3. open the folder where the apk is located "open-apk-path"

Note that if you want to build an AAB you can do it in the same way, just create a new task that produces the AAB. You will also need to add a task to open the AAB path, as it's not in the exact location of the APK.

Summary

We're done here, hopefully, I've made sense and given you all the necessary information to set up dotenv in your apps.

Resources


That's it for me this time! Have a great day!

💖 💪 🙅 🚩
nombrekeff
Keff

Posted on June 29, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related