Flutter, Fastlane, and Firebase App Distribution

Flutter, Fastlane, and Firebase App Distribution

ยท

20 min read

Recently I was tasked with implementing app distribution as part of our CI/CD process. Being generally allergic to DevOps, I would have immediately reached for CodeMagic. Unfortunately, our client required the use of Azure DevOps ๐Ÿ˜ž, but on the bright side, we were allowed to use Firebase App Distribution. Thus started the journey of integrating these moving pieces together in a way that is minimally invasive.

I will touch on Azure DevOps integration briefly, but this tutorial is meant to be as platform-agnostic as possible. We're Flutter developers after all - we don't believe in being tied down!

Prerequisites

This is an intermediate post, so I assume you have a basic knowledge of how to set up a Flutter application, how to create provisioning profiles and add users to them, and how to set up Firebase for your Android/iOS projects. If you do not, please refer to the links above. I apologize in advance for Apple's documentation resembling something from the early 2000s ๐Ÿ˜€.

1. Adding Fastlane for Android/iOS

Navigate to the Android directory of your flutter project and initialize fastlane.

fastlane init

You will be prompted to enter a package name and a secret JSON file location. Both of these are optional but we should add the package name from the AndroidManifest.xml.

Screen Shot 2020-02-12 at 9.14.38 PM.png

The output of running the command will be an Appfile containing the package name and a Fastfile containing some default commands that we can run. We will replace those.

Run the same command in your iOS directory. You will be prompted for an app identifier and an Apple ID. For the app identifier, use a wildcard domain. Wildcard domains allow us to sign multiple applications with the same profiles, so they are great for distribution. If you plan on using Apple capabilities such as Apple Pay, you will want to use an explicit domain. For the Apple ID use the email address associated with your developer account.

NOTE: In an organization, you will probably want to create a generic account purely for distribution like distribution@example.com, but we will not cover that here.

The output of running this command will closely mirror that of Android.

2. Add Match to iOS

Because iOS is awesome, we must use provisioning profiles to distribute the application in test environments. Using match will save us from the headache of managing this manually so lets set it up. First create a private git repository named ios-certificates.git then run the following command

bundle exec fastlane match init

You will be prompted to choose a storage location for your profile. Choose git and input the previously created url in the next prompt. The end result will be a Matchfile created that tells fastlane how to manage your distribution profile. Add the following code to it

app_identifier(["<<your wildcard domain>>"])
username("<<your apple id>>") # Your Apple Developer Portal username

Make sure to swap out the placeholders then run match again. Whenever a new iOS user is invited to the app, run match to update the distribution profile. The user should be able to install the application after a new build.

bundle exec fastlane match

You will be prompted for a password that match can use to encrypt the profile. It should be shared with your team members.

3. Add Firebase App Distribution plugin

Next, we need to add our fastlane plugin to distribute the application via Firebase App Distribution. Run the same command in both the android/ios directories.

fastlane add_plugin firebase_app_distribution

Now we can reference the plugin in our Fastfile commands.

3. Add App Distribution to Android

Add the following to your Fastfile

default_platform(:android)

APP_ID = ENV['FIREBASE_ANDROID_APPID']
FIREBASE_TOKEN = ENV['FIREBASE_CI_TOKEN']
BUILD_NUMBER = ENV["BUILD_NUMBER"]

platform :android do
  desc "Deploy a new beta"
  lane :distribute_beta do
      firebase_app_distribution(
          app: APP_ID,
          groups: "testers",
          release_notes: BUILD_NUMBER,
          firebase_cli_path: "/usr/local/bin/firebase",
          firebase_cli_token: FIREBASE_TOKEN,
          apk_path: "../build/app/outputs/apk/release/app-release.apk"
      )
  end
end

Let's break down each logical block.

default_platform(:android)

This block specifies that we are running fastlane for Android.

APP_ID = ENV['FIREBASE_ANDROID_APPID']
FIREBASE_TOKEN = ENV['FIREBASE_CI_TOKEN']
BUILD_NUMBER = ENV["BUILD_NUMBER"]

Next we assign a few environment variables to local variables in the Fastfile.

  • APP_ID - The Android application ID that was created during the Firebase setup.
  • FIREBASE_TOKEN - The Firebase token for CI usage.
  • BUILD_NUMBER - The build number associated with this build (from our CI environment).
platform :android do
  desc "Deploy a new beta"
  lane :distribute_beta do
      firebase_app_distribution(
          app: APP_ID,
          groups: "testers",
          release_notes: BUILD_NUMBER,
          firebase_cli_path: "/usr/local/bin/firebase",
          firebase_cli_token: FIREBASE_TOKEN,
          apk_path: "../build/app/outputs/apk/release/app-release.apk"
      )
  end
end

Finally we create a lane (command) called distribute_beta that will call the plugin we installed in the previous step. We pass it our local variables, as well as some information about where to find our bundle artifacts & Firebase and who to distribute the application to.

4. Add App Distribution to iOS

Now the real fun begins. Add the following to your iOS Fastfile

default_platform(:ios)

# Default temporary keychain password and name, if not included from environment
TEMP_KEYCHAIN_NAME_DEFAULT = "fastlane_flutter" || ENV['TEMP_KEYCHAIN_NAME'] 
TEMP_KEYCHAN_PASSWORD_DEFAULT = "temppassword" || ENV['TEMP_KEYCHAIN_PASSWORD']
APP_ID = ENV['FIREBASE_IOS_APPID']
FIREBASE_TOKEN = ENV['FIREBASE_CI_TOKEN']
BUILD_NUMBER = ENV["BUILD_NUMBER"]

# Remove the temporary keychain, if it exists
def delete_temp_keychain(name)
  delete_keychain(
    name: name
  ) if File.exist? File.expand_path("~/Library/Keychains/#{name}-db")
end

# Create the temporary keychain with name and password
def create_temp_keychain(name, password)
  create_keychain(
    name: name,
    password: password,
    unlock: false,
    timeout: false
  )
end

# Ensure we have a fresh, empty temporary keychain
def ensure_temp_keychain(name, password)
  delete_temp_keychain(name)
  create_temp_keychain(name, password)
end

platform :ios do
  desc "Build & sign iOS app"
  lane :build_ios do |options|
    disable_automatic_code_signing(
      path: "./Runner.xcodeproj",
      team_id: CredentialsManager::AppfileConfig.try_fetch_value(:team_id),
      profile_name: "match AdHoc #{CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier)}",
      code_sign_identity: "iPhone Distribution"
    )
    keychain_name = TEMP_KEYCHAIN_NAME_DEFAULT
    keychain_password = TEMP_KEYCHAN_PASSWORD_DEFAULT
    ensure_temp_keychain(keychain_name, keychain_password)
    match(
      app_identifier: CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier),
      type: "adhoc",
      readonly: is_ci,
      keychain_name: keychain_name,
      keychain_password: keychain_password,
      git_url: "<<git url>>"
    )
    build_ios_app(
      export_options: {
          method: "ad-hoc"
      },
      output_directory: "./build/Runner"
    )
    delete_temp_keychain(keychain_name)
  end

  desc "Deploy a new beta"
  lane :distribute_beta do |options|
    # Upload to test flight or AppStore depending on caller parameters
    firebase_app_distribution(
          app: APP_ID,
          groups: "testers",
          release_notes: BUILD_NUMBER,
          firebase_cli_path: "/usr/local/bin/firebase",
          firebase_cli_token: FIREBASE_TOKEN,
          ipa_path: "./build/Runner/Runner.ipa"
      )
  end
end

There's a lot here so again let's dissect each block.

default_platform(:android)

This block specifies that we are running fastlane for Android.

TEMP_KEYCHAIN_NAME_DEFAULT = "fastlane_flutter" || ENV['TEMP_KEYCHAIN_NAME'] 
TEMP_KEYCHAN_PASSWORD_DEFAULT = "temppassword" || ENV['TEMP_KEYCHAIN_PASSWORD']
MATCH_GIT_URL = ENV['MATCH_GIT_URL']
APP_ID = ENV['FIREBASE_IOS_APPID']
FIREBASE_TOKEN = ENV['FIREBASE_CI_TOKEN']
BUILD_NUMBER = ENV["BUILD_NUMBER"]

Along with local variables mirroring our Android Fastfile, we've defined a couple variables to help facilitate the installation of our provisioning profile. We also assign the git url necessary to download our provisioning profile using match.

def delete_temp_keychain(name)
  delete_keychain(
    name: name
  ) if File.exist? File.expand_path("~/Library/Keychains/#{name}-db")
end

def create_temp_keychain(name, password)
  create_keychain(
    name: name,
    password: password,
    unlock: false,
    timeout: false
  )
end

def ensure_temp_keychain(name, password)
  delete_temp_keychain(name)
  create_temp_keychain(name, password)
end

We then define a few utility functions to help manage the lifecycle of our temporary keychain user.

desc "Build & sign iOS app"
  lane :build_ios do |options|
    disable_automatic_code_signing(
      path: "./Runner.xcodeproj",
      team_id: CredentialsManager::AppfileConfig.try_fetch_value(:team_id),
      profile_name: "match AdHoc #{CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier)}",
      code_sign_identity: "iPhone Distribution"
    )
    keychain_name = ENV['TEMP_KEYCHAIN_NAME'] || TEMP_KEYCHAIN_NAME_DEFAULT
    keychain_password = ENV['TEMP_KEYCHAIN_PASSWORD'] || TEMP_KEYCHAN_PASSWORD_DEFAULT
    ensure_temp_keychain(keychain_name, keychain_password)
    match(
      app_identifier: CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier),
      type: "adhoc",
      readonly: is_ci,
      keychain_name: keychain_name,
      keychain_password: keychain_password,
      git_url: MATCH_GIT_URL
    )
    build_ios_app(
      export_options: {
          method: "ad-hoc"
      },
      output_directory: "./build/Runner"
    )
    delete_temp_keychain(keychain_name)
  end

Dissecting the build_ios lane, we

  • Disable automatic code signing for our CI/CD environment
  • Create a temporary keychain and install the certificates & profiles into that keychain using match.
  • Build the iOS application.

Unfortunately, I have not yet figured out how to "just sign" a previously built iOS application using fastlane, so we must build the application again.

  desc "Deploy a new beta"
  lane :distribute_beta do |options|
    # Upload to test flight or AppStore depending on caller parameters
    firebase_app_distribution(
          app: APP_ID,
          groups: "testers",
          release_notes: BUILD_NUMBER,
          firebase_cli_path: "/usr/local/bin/firebase",
          firebase_cli_token: FIREBASE_TOKEN,
          ipa_path: "./build/Runner/Runner.ipa"
      )
  end

Finally, we have reached the end, creating a lane that almost perfectly mirrors the one we used to distribute our Android application.

Extra Credit: Adding Azure DevOps for CI

Now let's reward ourselves with some Azure DevOps pipeline magic. Create a file in the project root and add the following code

variables:
  - group: <<your library group>>
  - name: projectDirectory
    value: $(System.DefaultWorkingDirectory)
  - name: FCI_BUILD_DIR
    value: .

trigger:
- master

pr:
- master

jobs:
  - job: BuildAndDistribute
    pool:
      vmImage: 'macOS-10.14'
    steps:
    - script: |
        curl -L https://raw.githubusercontent.com/eriwen/lcov-to-cobertura-xml/master/lcov_cobertura/lcov_cobertura.py -o lcov_cobertura.py
      displayName: Install code coverage dependencies
    - task: NodeTool@0
      inputs:
        versionSpec: '12.x'
      displayName: Install Node.js
    - task: UseRubyVersion@0
      inputs:
        versionSpec: '>= 2.4'
        addToPath: true
      displayName: Install Ruby
    - script: |
        gem install bundler
        cd $(projectDirectory)/ios && bundle update --bundler
        bundle install --retry=2 --jobs=4
        cd $(projectDirectory)/android && bundle update --bundler 
        bundle install --retry=2 --jobs=4
      displayName: Install Fastlane
    - task: FlutterInstall@0
      displayName: Install Flutter
    - task: FlutterTest@0
      inputs:
        projectDirectory: $(projectDirectory)
      displayName: Run tests
    - script: | 
        $(FLUTTERTOOLPATH)/flutter test --coverage
        python lcov_cobertura.py coverage/lcov.info --output coverage/coverage.xml --demangle
      displayName: Assemble code coverage results
    - task: PublishCodeCoverageResults@1
      inputs:
        codeCoverageTool: Cobertura
        summaryFileLocation: 'coverage/coverage.xml'
      displayName: Publish code coverage results
    - script: |
        echo $FCI_KEYSTORE_FILE | base64 --decode > $(projectDirectory)/android/app/keystore.jks
      displayName: Copy android keystore
      env:
        FCI_KEYSTORE_PASSWORD: $(FCI_KEYSTORE_PASSWORD)
        FCI_KEY_ALIAS: $(FCI_KEY_ALIAS)
        FCI_KEY_PASSWORD: $(FCI_KEY_PASSWORD)
    - task: FlutterBuild@0
      inputs:
        target: aab
        projectDirectory: $(projectDirectory)
      displayName: Build android artifacts
      env:
        FCI_KEYSTORE_PASSWORD: $(FCI_KEYSTORE_PASSWORD)
        FCI_KEY_ALIAS: $(FCI_KEY_ALIAS)
        FCI_KEY_PASSWORD: $(FCI_KEY_PASSWORD)
    - task: FlutterBuild@0
      inputs:
        target: apk
        projectDirectory: $(projectDirectory)
      displayName: Build android artifacts
      env:
        FCI_KEYSTORE_PASSWORD: $(FCI_KEYSTORE_PASSWORD)
        FCI_KEY_ALIAS: $(FCI_KEY_ALIAS)
        FCI_KEY_PASSWORD: $(FCI_KEY_PASSWORD)
    - task: FlutterBuild@0
      inputs:
        target: ios
        projectDirectory: $(projectDirectory)
        iosCodesign: false
      displayName: Build ios artifacts
    - script: |
        cd ios 
        bundle exec fastlane build_ios
        bundle exec fastlane distribute_beta
      displayName: Distribute iOS beta
      env:
        FIREBASE_IOS_APPID: $(FIREBASE_IOS_APPID)
        FIREBASE_CI_TOKEN: $(FIREBASE_CI_TOKEN)
        MATCH_PASSWORD: $(MATCH_PASSWORD)
        AZURE_TOKEN: $(AZURE_TOKEN)
        BUILD_NUMBER: $(Build.BuildNumber)
    - script: |
        cd android 
        bundle exec fastlane distribute_beta
      displayName: Distribute android beta
      env:
        FIREBASE_ANDROID_APPID: $(FIREBASE_ANDROID_APPID)
        FIREBASE_CI_TOKEN: $(FIREBASE_CI_TOKEN)
        BUILD_NUMBER: $(Build.BuildNumber)
    - task: CopyFiles@2
      inputs:
        contents: |
          **/release/**/*.aab
          **/release/**/*.apk
          **/*.ipa
        targetFolder: '$(build.artifactStagingDirectory)'
      displayName: Copy build artifacts
    - task: PublishBuildArtifacts@1
      displayName: publish build artifacts

There's quite a lot going on here so let's again break down these blocks.

variables:
  - group: <<your library group>>
  - name: projectDirectory
    value: $(System.DefaultWorkingDirectory)

trigger:
- master

pr:
- master

Here we import variables from a previously variable group and define another variable pointing to the project root. We set up the pipeline to run on pushes and pull requests to master.

jobs:
  - job: BuildAndDistribute
    pool:
      vmImage: 'macOS-10.14'

Next we create a job called BuildAndDistribute that will run on macOS.

steps:
    - script: |
        curl -L https://raw.githubusercontent.com/eriwen/lcov-to-cobertura-xml/master/lcov_cobertura/lcov_cobertura.py -o lcov_cobertura.py
    - task: NodeTool@0
      inputs:
        versionSpec: '12.x'
      displayName: Install Node.js
    - task: UseRubyVersion@0
      inputs:
        versionSpec: '>= 2.4'
        addToPath: true
      displayName: Install Ruby
    - script: |
        npm install -g firebase-tools
      displayName: Install Firebase CLI
    - script: |
        gem install bundler
        cd $(projectDirectory)/ios && bundle update --bundler
        bundle install --retry=2 --jobs=4
        cd $(projectDirectory)/android && bundle update --bundler 
        bundle install --retry=2 --jobs=4
      displayName: Install Fastlane
    - task: FlutterInstall@0
      displayName: Install Flutter

Grouping all of the setup steps together, first we install a script necessary to export our lcov test coverage to something a bit more archaic that Azure DevOps understands. Then we install Node.js & Ruby using plugins, Firebase and Fastlane using scripts, and our Flutter dependencies again using plugins. Most CIs have a way of doing similar installations with plugins.

- script: | 
        $(FLUTTERTOOLPATH)/flutter test --coverage
        python lcov_cobertura.py coverage/lcov.info --output coverage/coverage.xml --demangle
      displayName: Assemble code coverage results
    - task: PublishCodeCoverageResults@1
      inputs:
        codeCoverageTool: Cobertura
        summaryFileLocation: 'coverage/coverage.xml'
      displayName: Publish code coverage results

Next, we run our tests, run the script to convert the results, and publish those results for the pipeline to display.

- script: |
        echo $FCI_KEYSTORE_FILE | base64 --decode > $(projectDirectory)/android/app/keystore.jks
      displayName: Copy android keystore
      env:
        FCI_KEYSTORE_PASSWORD: $(FCI_KEYSTORE_PASSWORD)
        FCI_KEY_ALIAS: $(FCI_KEY_ALIAS)
        FCI_KEY_PASSWORD: $(FCI_KEY_PASSWORD)
    - task: FlutterBuild@0
      inputs:
        target: aab
        projectDirectory: $(projectDirectory)
      displayName: Build android artifacts
      env:
        FCI_KEYSTORE_PASSWORD: $(FCI_KEYSTORE_PASSWORD)
        FCI_KEY_ALIAS: $(FCI_KEY_ALIAS)
        FCI_KEY_PASSWORD: $(FCI_KEY_PASSWORD)
...

Here we download and decode keystore necessary to sign our application. We're using environment variables from the previously aforementioned variable group that we imported. We then build artifacts for Android (apk & aab) and iOS.

- script: |
        cd ios 
        bundle exec fastlane build_ios
        bundle exec fastlane distribute_beta
      displayName: Distribute iOS beta
      env:
        FIREBASE_IOS_APPID: $(FIREBASE_IOS_APPID)
        FIREBASE_CI_TOKEN: $(FIREBASE_CI_TOKEN)
        MATCH_PASSWORD: $(MATCH_PASSWORD)
        AZURE_TOKEN: $(AZURE_TOKEN)
        BUILD_NUMBER: $(Build.BuildNumber)
    - script: |
        cd android 
        bundle exec fastlane distribute_beta
      displayName: Distribute Android beta
      env:
        FIREBASE_ANDROID_APPID: $(FIREBASE_ANDROID_APPID)
        FIREBASE_CI_TOKEN: $(FIREBASE_CI_TOKEN)
        BUILD_NUMBER: $(Build.BuildNumber)

Now it's time to run our fastlane commands to distribute (and build for iOS) the application. Sweet Christmas! Again, we are passing in variables that were previously defined in our library and will be used in our Fastfile(s). There are two additions

  • MATCH_PASSWORD - this is the shared password used by match to encrypt the distribution profile.
  • AZURE_TOKEN - this is a personal access token that is used to download the match profile. Instead of using a normal git url, you will have to do something resembling
https://#{ENV['AZURE_TOKEN']}@dev.azure.com/repo_url/ios-certificates.git

Don't blame me, I just work here.

    - task: CopyFiles@2
      inputs:
        contents: |
          **/release/**/*.aab
          **/release/**/*.apk
          **/*.ipa
        targetFolder: '$(build.artifactStagingDirectory)'
      displayName: Copy build artifacts
    - task: PublishBuildArtifacts@1
      displayName: publish build artifacts

Finally, we copy all of our build artifacts to a staging area and publish them as part of the pipeline.

Recap

Congrats! We have automated the CI/CD for our Flutter application using Fastlane and Firebase App Distribution. And for extra credit, we threw a little Azure DevOps in there for free. Update that resume with some sweet DevOps skills ๐Ÿ˜Ž.

  • We can manage distribution locally or from CI/CD.
  • We can manage the update of our distribution profiles.
  • On every push and PR to master, all of our tests and builds will run.
  • On unsuccessful builds, the failure will be reported for the PR.
  • On successful builds, the app will be distributed on Firebase App Distribution.

Conclusion

I started on this journey with very little experience with fastlane and a whole lot of uncertainty concerning Azure DevOps. I pieced together articles that covered each unknown separately into this article documenting the process. I hope that this will aid you on your DevOps journey.

Sup?! Iโ€™m Ryan Edge. I am a Software Engineer at Superformula and a semi-pro open source contributor. If you liked this article, be sure to follow and spread the love! Happy trails