White-label mobile apps with Flutter & Fastlane

White-label mobile apps with Flutter & Fastlane

For more interesting content follow me on Twitter, I post daily there!

Recently, I’ve been working on an app core intended for distribution as a white-label solution. For those unfamiliar, a white-label solution involves creating code that can be customized into different apps for various clients. So basically it's the same app but can be with different design, backend endpoint, images/logos, sounds, etc.

Many restaurants run on same platform with just different color scheme, name and icon. There are many other industries where this approach is normal, as you don't need to develop app from scratch, but can use existing solutions.

In this article I want to show my approach to building white-label solutions with Flutter and Fastlane.

Why Fastlane you might ask? When you have fewer than five apps, manual deployment is manageable and doesn't take long. When you have few dozens of apps - you don't want to deploy it manually as it will take hours and hours of monotonous work.

Flutter

Before automating the build, we need to prepare our app for white-labeling. There are few general approaches. Some folks prefer to go with Flavors/BuildSchemes. I prefer to go with ENV variables.

In my case, the build process looks like this:

flutter build ipa --dart-define=MY_VAR1=value --dart-define=MY_VAR2=value

You can also use --dart-define-from-json and pass a path to json, but you'll see why I went this way

And then inside my flutter app I extract all ENV vars as constants to use later in my app

static const primaryColorHex = String.fromEnvironment('MAIN_COLOR');

Fastlane & CI builds

Now to more complex part. Building with CI is a regular thing. Most of apps are built in CI as part of CI CD pipelines. Either for testing purposes or for App Store deployment.

Since we already had few dozens on clients willing to have their white-labelled versions I had to come up with solution when I didn't have to spend days adding new clients. The solution is very simple - use Fastlane to build all apps at once with Github Actions(or any other CI provider). Fastlane is basically a set of Ruby lang methods that does something. There are existing lanes or you can create your own for your specific needs. Its best to have someone familiar with Ruby while creating your own custom lanes/steps

I started with json config file which looked like following. Where FIREBASE_PROJECT_ID is uniq value per each client(yes we use firebase for auth and some other things).

{ "FIREBASE_PROJECT_ID": {
    "APPSTORE_BUNDLE_ID": "com.myapp.client",
    "DEV_API_URL": "https://dev.endpoint.com",
    "PROD_API_URL": "https://prod.client.endpoint.com",
    "DISPLAY_NAME": "App Name",
    "COMPANY_NAME": "Company Name",
    "MAIN_COLOR": "#000000",
    "COUNTRY_CODE": "US",
    "LOGO_DATA": "..ommited BASE64 encoded image"
   }
}

To add a new client, I simply need to add a new section to this JSON file. Eventually this JSON file was moved to server and it takes few clicks to add new client via web form, but for ease of understanding lets stick to local JSON config.

Before actually deploying even test build we need manually create app bundle and app itself on appstoreconnect. We can automate this as well, but I haven't get there. Overall it takes 10-15 minutes to create new bundle ID and new app on appstoreconnect, fill in all information(it needs to be done only once per app). Same time applies to Google Play.

To save dev time I recorded few videos explaining how to do this, so even non tech staff can also do the job.

Now it's the most complicated step - building. Here's my Fastfile with comments on each step

default_platform(:ios)

GIT_AUTHORIZATION = ENV["GIT_AUTHORIZATION"]
TEMP_KEYCHAIN_USER = 'temp_keychain'
TEMP_KEYCHAIN_PASSWORD = 'temp_keychain_password'


# methods below are service methods that helps me to setup build config
def create_temp_keychain(name, password)
  create_keychain(
    name: name,
    password: password,
    unlock: false,
    timeout: 0
  )
end

def ensure_temp_keychain(name, password)
  create_temp_keychain(name, password)
end
# This method provides me specific PROJECT configuration based on
# provided ENV variable PROJECT ID.
# This is done in order to build single app per fastlane launch
def build_configuration
  raise 'PROJECT_ID is not set' unless ENV['PROJECT_ID']

  config = build_configurations[ENV['PROJECT_ID']]

  raise 'Configuration not found' unless config

  config
end

# This just parses json file and provides me a ruby JSON object with
# all configurations
def build_configurations
  @configs ||= JSON.parse(File.read('config.json'))
end

# This method creates me string with all my dart defines ENV vars
def build_args
  args = {
    'PROJECT_ID': build_configuration['PROJECT_ID'],
    ...other env vars
  }
  raise 'Build args is not set. Make sure you have following ' if args.values.any?(&:nil?)
  args.map { |k, v| "--dart-define=#{k}=\"#{v}\"" }.join(' ')
end


platform :ios do
  desc "Push a new beta build to TestFlight"

  # This lane launches github action that triggers 
  # new actions to build each app separately
  # In case 1 app crashes - you dont want to have all following apps
  # to be skipped.
  # This also allows you to restart failed job later
  lane :beta_all do
    api_endpoint = "https://api.github.com/repos/myrepo/myapp/actions/workflows/deploy_ios_project.yml/dispatches"
    auth_token = ENV["GIT_AUTHORIZATION"]

    build_configurations.each do |project_id, config|
      payload = { "ref" => "main", "inputs" => { "project-id" => project_id } }.to_json
      uri = URI.parse(api_endpoint)
      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = true
      request = Net::HTTP::Post.new(uri.request_uri, 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{auth_token}")
      request.body = payload
      response = http.request(request)

      unless response.code == "204"
        UI.error("Failed to trigger GitHub Action: #{response.code} - #{response.body}")
      end
    end


  end

  lane :beta do
    ENV["APP_IDENTIFIER"] = build_configuration['APPSTORE_BUNDLE_ID']
    ENV['APP_IDENTIFIERS'] = build_configurations.values.map{ |e| e['APPSTORE_BUNDLE_ID'] }.join(',')
    keychain_name = TEMP_KEYCHAIN_USER
    keychain_password = TEMP_KEYCHAIN_PASSWORD
    setup_ci if ENV['CI']
    ensure_temp_keychain(keychain_name, keychain_password)

    # IF BUILD_NUMBER is missing - we do not proceed
    # BUILD_NUMBER corresponds to CI build number
    raise 'BUILD_NUMBER is not set' unless ENV['BUILD_NUMBER']

    increment_build_number(xcodeproj: "Runner.xcodeproj", build_number: ENV['BUILD_NUMBER'])

    update_info_plist( # update app identifier string
      plist_path: "Runner/Info.plist",
      app_identifier: build_configuration['APPSTORE_BUNDLE_ID'],
      display_name: build_configuration['COMPANY_NAME']
    )

    match(type: 'appstore', readonly: false, git_basic_authorization: Base64.strict_encode64("name:#{GIT_AUTHORIZATION}"), app_identifier: build_configuration['APPSTORE_BUNDLE_ID'])

    update_project_provisioning(
      xcodeproj: "Runner.xcodeproj",
      build_configuration: "Release",
      target_filter: "Runner",
      profile: ENV["sigh_#{build_configuration['APPSTORE_BUNDLE_ID']}_appstore_profile-path"]
    )

    # In order to have own set of App Icons we need to generate IconSet for every client
    filename = "#{Time.now.to_i}_logo.png"
    # Decode the base64 string and write it to a file
    raise 'LOGO_DATA is missing. Please encode your 1024*1024 logo to base64 and add to LOGO_DATA key' unless build_configuration['LOGO_DATA']
    File.open(filename, "wb") do |file|
      file.write(Base64.decode64(build_configuration['LOGO_DATA']))
    end

    # Create appicon for ios
    base_dir = File.expand_path File.dirname(__FILE__)
    appicon_image_file = "#{base_dir}/#{filename}"
    appicon(appicon_image_file: appicon_image_file,
            appicon_path: "Runner/Assets.xcassets",
            appicon_devices: [:ipad, :iphone, :ios_marketing])

    # Create main_logo.png file which used as logo in app
    Dir.chdir("../../assets/images") do
      File.open('main_logo.png', "wb") do |file|
        file.write(Base64.decode64(build_configuration['LOGO_DATA']))
      end
    end

    # We need to run manual build since at the time of creating this lane there was no way to pass dart-define to xcode build
    sh("flutter pub get")
    sh("dart pub global activate flutterfire_cli")
    begin
      Dir.chdir "../.." do
        sh("flutterfire configure --yes --project=#{build_configuration['PROJECT_ID']} --web-app-id=#{build_configuration['APPSTORE_BUNDLE_ID']} --macos-bundle-id=#{build_configuration['APPSTORE_BUNDLE_ID']} --platforms=ios,android --ios-bundle-id=#{build_configuration['APPSTORE_BUNDLE_ID']} --windows-app-id=#{build_configuration['APPSTORE_BUNDLE_ID']} --ios-build-config=Release --ios-out=ios/Runner/GoogleService-Info.plist --android-package-name=#{build_configuration['APPSTORE_BUNDLE_ID']} --token #{ENV['AUTH_TOKEN']}")
      end
    end


    ### Set reversed client id to Info.plist. This needed for google sign in to work on ios platform
    reversed_client_id = nil
    Dir.chdir "../" do
      google_service_info_path = "Runner/GoogleService-Info.plist"
      google_service_info = Plist.parse_xml(google_service_info_path)

      reversed_client_id = google_service_info["REVERSED_CLIENT_ID"]
    end

    update_info_plist( #update CFBundleURLTypes
      xcodeproj: "Runner.xcodeproj",
      plist_path: "Runner/Info.plist",
      block: proc do |plist|
        url_types = plist["CFBundleURLTypes"]
        if url_types && !url_types.empty?
          url_type = url_types[0]
          url_type["CFBundleURLName"] = build_configuration['APPSTORE_BUNDLE_ID']
          if url_type["CFBundleURLSchemes"] && !url_type["CFBundleURLSchemes"].empty?
            url_type["CFBundleURLSchemes"][0] = reversed_client_id
          end
        end
      end
    )

    sh 'pod install'
    # Run flutter build ios to generate proper flutter_export_environment.sh. No need to sign app yet
    sh "flutter build ipa #{build_args} --no-codesign"

    build_app(workspace: "Runner.xcworkspace", scheme: "Runner")
    upload_to_testflight(skip_waiting_for_build_processing: true)
    delete_temp_keychain(keychain_name)
    puts "Build #{lane_context[SharedValues::BUILD_NUMBER]} was uploaded to Test Flight"
  end
end

While this flow might look complex - it wasn't done all at once. I was doing step by step to get a build in the end. In your case you might not have firebase, or you might need an extra step to add something specific to your app.

Running in CI

In my Fastfile above you noticed I have 2 lanes, one to deploy app apps and 1 to deploy single app. That was done on purpose. It's simple - when I have new app version I want to deploy them all at once. But I also want to restart build if it fails for some reason(timeout, etc).

Note - GitHub actions uses 4x minutes to build iOS apps, so run debug your Fastfile locally until it completely succeeds.

Conclusion

I skipped part with App Store release, since it's just 2 lines of code calling according Lane. I also skipped google play, since its the same steps(just different on build.gradle configuration)

Setting up this pipeline helped me to offload all routine work to be done by CI and support team. When support team wants to add new client - they add it via web form and create according apps in appstore/google play. Whole process takes less than 30 minutes and has to be done only once. All next deploys are fully automatic.

With this flow we save hundreds of hours every month and the more clients we have more time we save.

Follow me on Twitter for more interesting content.