← blog

2021-06-14

White-labeling one Flutter app for many customers: how I did it

A learning platform ships a Flutter mobile app. Enterprise customers want their own app on the App Store and Google Play: their name, icon, splash screen, bundle id and deep-link domain, but still one codebase underneath. This post covers how I split the problem into three layers, which mechanism each one uses (with code), and what I haven't solved. Customer names, domains and every ID are hidden or replaced with fake values; I call the base flavor base and an example tenant tenantc.

The problem

"White-label" sounds like swapping a logo, but on mobile it is three different jobs:

  1. App identity: bundle id, display name, icon, splash, Firebase project, deep-link domain. This is what the stores see and it can't change after build.
  2. Runtime branding: the logo and title on the login screen, the notification icon. The backend can change these without shipping a new release.
  3. Delivery: every tenant is a separate app, so it needs its own build, signing and upload, yet the pipeline can't be copy-pasted per tenant.

Forcing all three into one mechanism breaks: put identity on the backend and the stores can't see it; put branding in the build and every logo change becomes a release.

Layer 1: build-time identity

The core idea: one source of truth in .env files, loaded with --dart-define-from-file. The same set of variables feeds three places: Dart (compile-time constants), Gradle (Android) and Xcode (iOS). Nothing declares a bundle id or app name twice.

One env file per tenant

Each tenant is a flavor plus a file environment_configurations/tenant/.tenantc.env:

ini
COMPANY_NAME=tenantc
APP_DISPLAY_NAME="Tenant C Academy"
APP_LINK_DOMAIN_PREFIX=tenantc.
APP_STORE_PATH=/tenant-c-academy/id0000000000
PROVISIONING_PROFILE_NAME=Tenantc
PROVISIONING_PROFILE_NAME_PREFIX=Prod
GOOGLE_SIGNIN_SERVER_CLIENT_ID=...
GOOGLE_SIGNIN_IOS_URL_SCHEME=...
GOOGLE_SIGNIN_IOS_CLIENT_ID=...

What every tenant shares lives in .general.env, so a bundle id is just string concatenation:

ini
APPLICATION_ID_PREFIX=com.
APPLICATION_ID_TYPE=.vle
ROOT_APP_LINK_DOMAIN=example.com

At build time up to five files are layered: API environment, tenant, shared config, and two certificate files. The example shows the first three.

bash
E=environment_configurations   # shortened for reading
flutter run --flavor tenantc \
  --dart-define-from-file $E/api/.production.env \
  --dart-define-from-file $E/tenant/.tenantc.env \
  --dart-define-from-file $E/.general.env

Adding a tenant means adding exactly one file like this, with no bundle id to invent: com. + tenantc + .vle = com.tenantc.vle.

Dart: reading dart-defines and picking the flavor

On the Dart side the flavor comes from Flutter's appFlavor (set by --flavor), and every other value is a compile-time constant:

dart
Future<void> initializeApp() async {
  flavor = Flavor.convert(appFlavor!);
  await Firebase.initializeApp(options: flavor.firebaseOptions);
  // ...
}
 
// deep-link domain = tenant prefix + root domain
String get domain =>
    const String.fromEnvironment('APP_LINK_DOMAIN_PREFIX') +
    const String.fromEnvironment('ROOT_APP_LINK_DOMAIN');

Whatever must differ per flavor (Firebase, analytics) is looked up with a switch over the enum, so forgetting a branch for a new tenant is a compile error, not a runtime one:

dart
ClarityConfig get clarityConfiguration => switch (this) {
  Flavor.development ||
  Flavor.staging ||
  Flavor.base => clarity.baseConfiguration,
  Flavor.tenantA => clarity.tenantAConfiguration,
  Flavor.tenantC => clarity.tenantCConfiguration,
};

Each flavor also has an ApplicationConfigurator registered with injectable per environment. Tenants extend the base config and override nothing, so the new class only tells DI the flavor exists:

dart
@LazySingleton(as: ApplicationConfigurator)
@tenantc
final class TenantCAppConfig extends BaseAppConfig {
  TenantCAppConfig();
}

A tenant's assets get bundled only into its own app, thanks to flavors: in pubspec.yaml:

yaml
flutter:
  assets:
    - assets/images/
    - path: assets/tenantc/
      flavors:
        - tenantc

Gradle: embedding dart-defines in the Android build

When you run flutter build or flutter run, Flutter hands every dart-define to Gradle through the dart-defines property: KEY=VALUE pairs, each base64-encoded, comma-separated. --flavor is passed along as FLUTTER_APP_FLAVOR. Gradle only has to decode that string:

kotlin
val dartDefineVariables = if (project.hasProperty("dart-defines")) {
    (project.property("dart-defines") as String)
        .split(",")
        .associate { entry ->
            val decoded = String(
                Base64.getDecoder().decode(entry), Charsets.UTF_8
            )
            val pair = decoded.split("=", limit = 2)
            pair[0] to pair.getOrElse(1) { "" }
        }
        .filterKeys { it.matches(Regex("^[a-zA-Z_][a-zA-Z0-9_]*$")) }
} else {
    emptyMap<String, String>()
}

Then those variables drive applicationId, the display name and the deep-link domain. The filterKeys line is a guard that only accepts valid variable names, and split(limit = 2) keeps any = inside a value:

kotlin
// the repo writes this inline; folded into a helper here
fun define(key: String) = dartDefineVariables[key] ?: ""
 
android {
    defaultConfig {
        applicationId = define("APPLICATION_ID_PREFIX") +
            define("COMPANY_NAME") +
            define("APPLICATION_ID_TYPE") +
            define("APPLICATION_ID_SUFFIX")
        resValue(
            "string",
            "app_display_name",
            define("APP_DISPLAY_NAME")
        )
        resValue(
            "string",
            "app_link_domain",
            define("APP_LINK_DOMAIN_PREFIX") +
                define("ROOT_APP_LINK_DOMAIN")
        )
    }
    flavorDimensions += "app"
    productFlavors {
        // used to list every flavor; now only the one being built
        create(define("FLUTTER_APP_FLAVOR")) { dimension = "app" }
    }
}

AndroidManifest.xml no longer holds hardcoded values, it only references the generated resources:

xml
<application android:label="@string/app_display_name" ...>
  <intent-filter android:autoVerify="true">
    <data android:host="@string/app_link_domain" />
  </intent-filter>
</application>

Xcode: a build step that turns dart-defines into an xcconfig

iOS is harder because Xcode doesn't read dart-defines. My approach: a script that runs before the build, decodes DART_DEFINES (same base64 format as above) and writes an .xcconfig file:

sh
# ios/flutter_extract_dart_define.sh (trimmed)
function entry_decode() { echo "${*}" | base64 --decode; }
 
out=${SRCROOT}/Flutter/DartDefineVariables.xcconfig
rm -rf "$out"
echo "// Generated file; do not edit or commit." > "$out"
 
IFS=',' read -r -a items <<<"$DART_DEFINES"
valid=(
  "FLUTTER_BUILD_NAME=$FLUTTER_BUILD_NAME"
  "FLUTTER_BUILD_NUMBER=$FLUTTER_BUILD_NUMBER"
  "FLAVOR=$FLAVOR"
)
for item in "${items[@]}"; do
  decoded=$(entry_decode "$item")
  # only accept KEY=VALUE lines with a valid variable name
  if echo "$decoded" | grep -q "^[a-zA-Z_][a-zA-Z0-9_]*=.*$"; then
    valid+=("$decoded")
  fi
done
 
printf "%s\n" "${valid[@]}" >> "$out"

The script is attached as a scheme Pre-action (Edit Scheme, Build, Pre-actions), next to the stock "Prepare Flutter Framework" step. Each flavor has its own scheme, so each scheme carries this step:

xml
<PreActions>
  <ExecutionAction ActionType="...ExecutionActionType.ShellScriptAction">
    <ActionContent
       title="Extract Dart-define"
       scriptText="sh ${SRCROOT}/flutter_extract_dart_define.sh">
    </ActionContent>
  </ExecutionAction>
</PreActions>

The generated file (gitignored) looks like this:

ini
// Generated file; do not edit or commit.
FLUTTER_BUILD_NAME=1.2.3
FLUTTER_BUILD_NUMBER=100
FLAVOR=tenantc
COMPANY_NAME=tenantc
APP_DISPLAY_NAME=Tenant C Academy
APPLICATION_ID_PREFIX=com.
...

That file is the base configuration of the Runner target's build configurations. From then on every $(VARIABLE) in build settings and Info.plist resolves, and the bundle id formula is identical to Android's:

ini
# one line in project.pbxproj, broken up here for readability
PRODUCT_BUNDLE_IDENTIFIER =
  "$(APPLICATION_ID_PREFIX)$(COMPANY_NAME)"
  "$(APPLICATION_ID_TYPE)$(APPLICATION_ID_SUFFIX)";
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-$(FLAVOR)";
LAUNCH_SCREEN_STORYBOARD = "LaunchScreen$(PROVISIONING_PROFILE_NAME)";

In Info.plist, the display name and the Google sign-in URL scheme are plain variable references too:

xml
<string>$(APP_DISPLAY_NAME)</string>
<string>$(GOOGLE_SIGNIN_IOS_URL_SCHEME)</string>

Firebase's GoogleService-Info.plist is picked by a separate build phase: the build configuration is named like Release-tenantc, a regex takes the part after the last dash as the flavor name and copies the matching file from ios/config/:

sh
if [[ $CONFIGURATION =~ -([^-]*)$ ]]; then
  environment=${BASH_REMATCH[1]}
fi
FILE=${PROJECT_DIR}/config/${environment}/GoogleService-Info.plist
cp "$FILE" "${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app"

Icons and native splash: generated from per-flavor config

Icons and splash screens eat the most time if done by hand: every tenant needs a dozen sizes for two platforms, plus dark variants and an Android 12 splash. I let two packages generate all of it, one YAML file per flavor.

Source images live at launcher_icon/android/tenantc.png, launcher_icon/ios/tenantc.png, splash_logo/tenantc.png and splash_logo/android_12/tenantc.png. The icon config:

yaml
# flutter_launcher_icons-tenantc.yaml
flutter_launcher_icons:
  android: true
  image_path_android: "launcher_icon/android/tenantc.png"
  min_sdk_android: 22
  adaptive_icon_background: "#ffffff"
  adaptive_icon_foreground: "launcher_icon/android/tenantc.png"
  adaptive_icon_foreground_inset: 0
  ios: true
  image_path_ios: "launcher_icon/ios/tenantc.png"
  remove_alpha_ios: true

The splash config:

yaml
# flutter_native_splash-tenantc.yaml
flutter_native_splash:
  color: "#FFFFFF"
  image: splash_logo/tenantc.png
  color_dark: "#FFFFFF"
  image_dark: splash_logo/tenantc.png
  android_12:
    image: splash_logo/android_12/tenantc.png
    image_dark: splash_logo/android_12/tenantc.png
  android: true
  ios: true
  web: false
  android_gravity: center
  ios_content_mode: center

Then two commands. Without -f, flutter_launcher_icons scans for every flutter_launcher_icons-<flavor>.yaml and generates for each flavor; for splash, the -A flag (--all-flavors) does the same:

bash
dart run flutter_launcher_icons
dart run flutter_native_splash:create -A

The output lands where the flavored build picks it up on its own:

The generated adaptive icon is small, and ic_launcher_background comes from adaptive_icon_background in the YAML:

xml
<adaptive-icon
    xmlns:android="http://schemas.android.com/apk/res/android">
  <background android:drawable="@color/ic_launcher_background"/>
  <foreground>
    <inset
        android:drawable="@drawable/ic_launcher_foreground"
        android:inset="0%" />
  </foreground>
</adaptive-icon>

One detail ties the two worlds together. The splash package names the storyboard after the capitalized flavor name (LaunchScreenTenantc), while the LAUNCH_SCREEN_STORYBOARD build setting above is composed from PROVISIONING_PROFILE_NAME. So the env file sets PROVISIONING_PROFILE_NAME=Tenantc to match exactly. It works, but it's a signing variable doing double duty as an asset-naming input, an implicit coupling a newcomer would never guess.

Layer 2: runtime branding, the backend decides

What's shown at runtime comes from a backend whitelabel endpoint, called with the tenant's domain and a page_name:

dart
@GET('/whitelabel')
Future<HttpResponse<dynamic>> fetchWhitelabel({
  @Query('page_name') String? pageName, // 'login', 'notification'
  @Query('domain') String? domain,
});

The repository pulls logo and title out of the mobile payload. A missing field becomes an empty string rather than an exception:

dart
final config = (((response['items'] as List?)?.firstOrNull
    as Map?)?['data'] as Map?)?['mobile'] as Map?;
return LoginConfigurations(
  logo: config?['logo'] as String? ?? '',
  title: config?['title'] as String? ?? '',
);

The UI has two layers of protection: only white-label flavors take the custom branch, and that branch always checks for empty values so it can fall back to the default logo.

dart
/// false for the base flavor and internal environments
bool get isWhiteLabel => switch (this) {
  Flavor.development || Flavor.staging || Flavor.base => false,
  _ => true,
};
 
// login_screen.dart
child: flavor.isWhiteLabel && state.logoUrl.isNotEmpty
    ? VleNetworkImage(image: state.logoUrl)
    : AppImages.logo(),

Every request also carries an x-tenant-code header (from TENANT_CODE in the env) so the backend knows which tenant it is serving:

dart
options.headers.putIfAbsent('x-tenant-code', () => tenantCode);

Layer 3: delivery, one template, one variable block per tenant

CI on GitLab composes small blocks with extends. Adding a tenant means adding one variable block and two jobs (IDs replaced with fake values):

yaml
.common_tenantc_variables_extension:
  variables:
    FIREBASE_ANDROID_APP_ID:
      value: '1:000000000000:android:aaaaaaaaaaaaaaaa'
      expand: false
    FIREBASE_IOS_APP_ID:
      value: '1:000000000000:ios:bbbbbbbbbbbbbbbb'
      expand: false
    FLAVOR:
      value: 'tenantc'
      expand: false
    APP_NAME:
      value: 'Tenantc'
      expand: false
    PACKAGE_ID: com.$FLAVOR.vle
 
# push to the stores: manual, only after the base app has been released
distribute_tenantc:
  rules:
    - !reference [ .tenant_appstore_build_rules, rules ]
  extends:
    - .distribute_extension
    - .common_production_extension
    - .common_appstore_variables_extension
    - .common_tenantc_variables_extension
 
# push a test build to Firebase App Distribution for testers
distribute_pre_tenantc:
  rules:
    - !reference [ .pre_appstore_build_rules, rules ]
    - !reference [ .manual_build_rules, rules ]
  extends:
    - .distribute_extension
    - .common_production_extension
    - .firebase_distribution_variables_extension
    - .common_pre_appstore_variables_extension
    - .common_tenantc_variables_extension

From FLAVOR, the later steps derive themselves, so nothing else needs declaring:

yaml
GYM_SCHEME: $FLAVOR   # iOS scheme for Fastlane
# … is the output / certificates folder
SUPPLY_AAB: "…/bundle/${FLAVOR}Release/app-$FLAVOR-release.aab"
FIREBASE_SERVICE_CREDENTIALS_FILE:
  "…/$FLAVOR/firebase-console-service-key.json"

Each tenant has its own Firebase project, so one customer's analytics and crashes never mix with another's. The app also gets quick updates over the air (Shorebird) and checks the latest build number from a config file on a CDN, keyed per flavor.

How CD runs for one tenant

The variable block above is only the declaration. Here is how a real release moves through the pipeline. A release is a tag tenant-vX.Y.Z plus a pipeline triggered through the API, and the distribute_* jobs are filtered by the variable MANUAL_PIPELINE_OPTIONS (build, release or patch):

tag tenant-vX.Y.Z + API (MANUAL_PIPELINE_OPTIONS=release)
│
├─ distribute_base          automatic
│    └─ Shorebird release → App Store + Google Play
│       (records the build number)
├─ distribute_tenantc       manual
│    └─ same version + build number → tenant C's stores
└─ distribute_pre_tenantc   manual
     └─ test build → Firebase App Distribution

Three modes, three rule sets. The only difference between the base app and a tenant is when (I broke the if conditions across lines for readability; they are unchanged):

yaml
# base app: runs right after the earlier stages
.appstore_build_rules:
  rules:
    - if: >-
        $CI_PIPELINE_SOURCE == "api"
        && $MANUAL_PIPELINE_OPTIONS == "release"
        && $CI_COMMIT_TAG =~ /^tenant-v\d+\.\d+\.\d+$/
        && $RELEASE_PASSWORD == $DEFAULT_RELEASE_PASSWORD
      when: on_success
 
# tenant: same condition, but someone has to press play
.tenant_appstore_build_rules:
  rules:
    - if: >-
        $CI_PIPELINE_SOURCE == "api"
        && $MANUAL_PIPELINE_OPTIONS == "release"
        && $CI_COMMIT_TAG =~ /^tenant-v\d+\.\d+\.\d+$/
        && $RELEASE_PASSWORD == $DEFAULT_RELEASE_PASSWORD
      when: manual
 
# internal test build: only builds the chosen tenant
.manual_build_rules:
  rules:
    - if: >-
        $CI_PIPELINE_SOURCE == "api"
        && $MANUAL_PIPELINE_OPTIONS == "build"
        && $TENANT_ENV == $FLAVOR
        && $CI_COMMIT_BRANCH
      when: on_success

The patch mode (over-the-air updates through Shorebird, no store review) filters the same way but requires a PATCH_TAG in semver format. The RELEASE_PASSWORD condition is a guard so nobody triggers a real release by accident.

Each distribute_* job is only a shell. It doesn't build anything itself; it triggers one shared child pipeline and forwards every variable (including FLAVOR). The real build runs on a dedicated macOS runner, and resource_group makes tenants build one at a time instead of stepping on each other:

yaml
.distribute_extension:
  stage: distribution
  trigger:
    include: "${CI_TEMPLATE_DIRECTORY}build${CI_TEMPLATE_EXTENSION}"
    strategy: depend
    forward:
      pipeline_variables: true
 
# build.gitlab-ci.yml
build:
  tags: [ mobile_distribution ]
  resource_group: mobile_distribution

Tenants share the base app's version and build number. This is the part that took me the most effort to get right. When the base app is released to the stores, the job writes the build number to a per-version file. Every tenant (and every pre test build) reads exactly that number back, so one tag produces the same build number in every app. If the file isn't there, the job stops with a clear error instead of inventing a different number:

sh
# tenants, pre builds and patches reuse the base app's build number
reuse=false
[[ $MANUAL_PIPELINE_OPTIONS == 'patch' ]] && reuse=true
if [[ $MANUAL_PIPELINE_OPTIONS == 'release' ]]; then
  [[ $BUILD_OPTION == 'pre' || $FLAVOR != 'base' ]] && reuse=true
fi
 
if $reuse; then
  if [[ ! -f "$BUILD_NUMBER_FILE" ]]; then
    echo "Error: release the base app for this version first"
    exit 1
  fi
  export BUILD_NUMBER="$(cat "$BUILD_NUMBER_FILE")"
else
  # internal build: bump the counter
  export BUILD_NUMBER="$(yq '.build_number + 1' "$BUILD_ENV_FILE")"
fi
 
# after the base app reaches the stores: record this version's build number
if [[ $MANUAL_PIPELINE_OPTIONS == 'release' && $FLAVOR == 'base' &&
      $BUILD_OPTION != 'pre' ]]; then
  echo "$BUILD_NUMBER" > "$BUILD_NUMBER_FILE"
fi

So the "base app first, tenants after" order needs no verbal convention: a tenant triggered too early fails by itself.

iOS signing per tenant. Each tenant is a separate bundle id, so it needs its own provisioning profile, for the app and for the image-notification extension. The profile name is derived from APP_NAME in the variable block, which is why the block must have it:

sh
(cd ios &&
  bundle exec fastlane sigh -a $APP_PACKAGE_ID &&
  bundle exec fastlane sigh -a "$APP_PACKAGE_ID.ImageNotification")
ruby
gym(export_options: { provisioningProfiles: {
  app_package_id =>
    "Prod Tenantc (AppStore)",
  "#{app_package_id}.ImageNotification" =>
    "Prod Tenantc ImageNotification (AppStore)",
}})

Fastlane picks the action by mode. iOS and Android share the same two lanes, build and upload. The build command is always the five dart-define files from Layer 1, with flavor taken from FLAVOR:

ruby
# trimmed from the real Fastfile for readability
def build
  case get_manual_pipeline_options
  when ManualPipelineOptions::PATCH
    # OTA, no store build
    shorebird_patch
  when ManualPipelineOptions::RELEASE
    if is_pre_appstore_build
      # test build
      flutter_build
      export_ipa if is_ios
    else
      shorebird_release
      export_ipa if is_ios
    end
  else
    flutter_build
    export_ipa if is_ios
  end
end
 
def upload
  if patch?
    upload_patch # notification only
  elsif release_to_store?
    upload_store # deliver (iOS) or supply (Android)
  else
    upload_firebase_distribution
  end
end

The upload is per tenant too: upload_store pushes debug symbols to Crashlytics with that tenant's own Firebase app id (FIREBASE_IOS_APP_ID / FIREBASE_ANDROID_APP_ID), then calls deliver or supply with PACKAGE_ID. upload_firebase_distribution uses the service-account key from a folder named after $FLAVOR, then saves the install link so a later step can post a comment on the Jira ticket.

So what does adding a tenant to CD cost? One variable block, two extends jobs, one Firebase key folder, two provisioning profiles. No build, signing or upload step has to be rewritten, since they all read from FLAVOR.

Three tenants, three different lessons

I have the git history of all three, so I can compare:

A lesson about documentation: I wrote the add-a-tenant process as a skill for a coding agent, then let it go stale as the code changed (a package path moved, Gradle became dynamic). Process docs are code too and need checking when things change.

What's still unsolved

Takeaway

White-labeling a mobile app is three problems with different lifetimes. What the stores see is fixed at build time and composed by formula from one env file. What users see and may change is decided by the backend, with a fallback. What repeats per tenant belongs to CI and scripts. Later tenants get cheaper, but only if you spend the effort to turn the previous tenant's manual steps into something a machine does.