Native Build Env SDK

Flutter plugin đọc cấu hình công khai được tạo trong native build, không dùng --dart-define và không thêm .env vào Flutter assets.

Luồng hoạt động

Android runtime:
.env -> Gradle flavor -> Android string resource -> Kotlin -> MethodChannel

iOS compile-time:
Scheme Pre-action -> .envfile -> Swift generator -> Flutter/tmp.xcconfig

iOS runtime:
.envfile -> CocoaPods before_compile -> generated Objective-C header
         -> Objective-C plugin -> MethodChannel

SDK không bảo vệ secret. Mọi giá trị nằm trong APK/IPA đều có thể bị trích xuất. Chỉ dùng cho URL, environment name, feature flag, public client ID và public key đã được giới hạn quyền.

1. Thêm dependency

Trong giai đoạn beta, nên pin chính xác phiên bản:

dependencies:
  native_build_env_sdk: 0.1.0-beta.1

Với package local:

dependencies:
  native_build_env_sdk:
    path: ../flutter_native_env

Sau đó chạy:

fvm flutter pub get

2. Tạo file môi trường

Có thể đặt file ở bất kỳ đâu. Ví dụ tại Flutter project root:

.env.development
.env.staging
.env.production
APP_ENV=development
APP_NAME="Example Development"
API_BASE_URL=https://dev-api.example.com
ENABLE_LOGGING=true

Không khai báo các file này trong flutter.assets. Parser hỗ trợ KEY=VALUE, comment bắt đầu bằng #, single/double quote và tiền tố export. Parser không hỗ trợ expansion như $OTHER_KEY.

3. Android

Đặt cấu hình SDK sau khi apply Android/Flutter plugins nhưng trước block android { ... }. Script đọc env của flavor đang được build và expose map tại project.env, tương tự flutter_config:

project.ext.nativeBuildEnvFiles = [
    //{flavor}: {env path}
    development: ".env.development",
    staging    : ".env.staging",
    production : ".env.production",
]

project.ext.nativeBuildEnvDefaultFlavor = "development"
project.ext.nativeBuildEnvRequiredKeys = ["APP_ENV", "API_BASE_URL"]

apply from: project(':native_build_env_sdk').projectDir.getPath() +
    "/native_build_env.gradle"

nativeBuildEnvDefaultFlavor được dùng khi Gradle sync hoặc task không chứa tên flavor. Khi chạy task như assembleProductionRelease, script tự chọn .env.production. Có thể override rõ ràng bằng -PnativeBuildEnvFlavor=production hoặc environment variable NATIVE_BUILD_ENV_FLAVOR.

Đường dẫn tương đối được tính từ Flutter project root; đường dẫn tuyệt đối và File cũng được hỗ trợ. Key trong map phải khớp tên productFlavors.

Đọc env trong app/build.gradle

Sau dòng apply from, đọc value trực tiếp:

def verName = project.env.get("VER_NAME")
if (verName == null || verName.isEmpty()) {
    verName = "1.0"
}

def verBuild = project.env.get("VER_BUILD")
if (verBuild == null || verBuild.isEmpty()) {
    verBuild = "1"
}

App tự quyết định dùng values ở đâu; plugin không tự ghi đè Flutter config:

android {
    defaultConfig {
        versionName verName
        versionCode verBuild.toInteger()
    }

    flavorDimensions "environment"
    productFlavors {
        development { dimension "environment" }
        staging { dimension "environment" }
        production { dimension "environment" }
    }
}

Ví dụ env:

VER_NAME=1.2.3-dev
VER_BUILD=12301

Nếu muốn fallback về version Flutter thay vì literal, dùng:

def verName = project.env.get("VER_NAME") ?: flutter.versionName
def verBuild = (project.env.get("VER_BUILD")
        ?: flutter.versionCode.toString()).toInteger()

Do app tự gán version, precedence hoàn toàn nằm trong code của app; SDK chỉ đọc và expose string values.

Dùng env value ở Android compile-time

Mỗi env key tự động trở thành string resource, không cần khai báo danh sách:

KEY_NAME -> @string/native_build_env_key_name

Với APP_NAME="Example Development", plugin tạo:

@string/native_build_env_app_name

Dùng trong android/app/src/main/AndroidManifest.xml:

<application
    android:label="@string/native_build_env_app_name"
    android:name="${applicationName}"
    android:icon="@mipmap/ic_launcher">
    <!-- ... -->
</application>

Plugin vẫn serialize toàn bộ map thành Base64 JSON cho runtime Flutter. Nếu hai key chỉ khác hoa/thường tạo cùng Android resource name, build sẽ fail.

Cấu hình cũ ext.nativeBuildEnv = [envDir: ..., files: ...] vẫn được hỗ trợ.

fvm flutter run --flavor development
fvm flutter build appbundle --flavor production

4. iOS

Mỗi flavor iOS tương ứng với một Xcode Scheme. iOS dùng cùng một file .envfile cho hai pipeline độc lập:

  • Scheme Pre-action tạo tmp.xcconfig để dùng env trong Xcode/Info.plist.
  • CocoaPods script phase tự generate runtime config trước khi compile plugin.

App không cần thêm Runner Build Phase, JSON resource hoặc Info.plist payload.

4.1. Cập nhật CocoaPods

Sau khi thêm hoặc nâng phiên bản plugin, chạy pod install trong thư mục ios. Podspec tự thêm phase Generate Native Build Env vào pod target với vị trí before_compile.

4.2. Chọn env trong từng Scheme

Trong Xcode, mở:

Product > Scheme > Edit Scheme > Build > Pre-actions

Chọn Runner tại Provide build settings from. Với Scheme development, thêm script:

set -eu

echo ".env.development" > "${SRCROOT}/.envfile"

/bin/sh \
  "${SRCROOT}/.symlinks/plugins/native_build_env_sdk/ios/Scripts/RunNativeBuildEnvGenerator.sh" \
  --ios-root "${SRCROOT}" \
  --format xcconfig \
  --output "${SRCROOT}/Flutter/tmp.xcconfig"

Mỗi Scheme phải có toàn bộ Pre-action trên để luôn tạo lại cả .envfiletmp.xcconfig. Với Scheme production, dùng cùng script nhưng chọn file khác:

set -eu

echo ".env.production" > "${SRCROOT}/.envfile"

/bin/sh \
  "${SRCROOT}/.symlinks/plugins/native_build_env_sdk/ios/Scripts/RunNativeBuildEnvGenerator.sh" \
  --ios-root "${SRCROOT}" \
  --format xcconfig \
  --output "${SRCROOT}/Flutter/tmp.xcconfig"

Wrapper loại các biến iOS SDK do Xcode truyền vào trước khi chạy Swift bằng macOS SDK. Việc này tránh lỗi trộn iPhoneSimulator sysroot với macOS target trong Scheme Pre-action.

.envfiletmp.xcconfig được dùng chung giữa các Scheme; Pre-action của Scheme đang build sẽ ghi đè chúng bằng môi trường tương ứng. .envfile chỉ chứa tên hoặc đường dẫn env, không chứa values. Đường dẫn tương đối được resolve từ Flutter project root; đường dẫn tuyệt đối cũng được hỗ trợ.

4.3. Dùng env ở compile-time

Thêm vào cuối các Runner xcconfig, ví dụ Debug.xcconfigRelease.xcconfig:

#include? "tmp.xcconfig"

Sau đó có thể dùng env như Xcode Build Settings. Ví dụ trong Info.plist:

<key>CFBundleDisplayName</key>
<string>$(APP_NAME)</string>

Hoặc dùng $(API_BASE_URL) trong một build setting khác. Generator escape URL để // không bị xcconfig hiểu là comment.

Xcode có thể nạp xcconfig trước Scheme Pre-action trong clean build đầu tiên. Nếu compile-time value chưa cập nhật, build lại một lần. Điều này không ảnh hưởng runtime config vì pod script phase đọc .envfile trực tiếp sau Pre-action.

4.4. Runtime config tự động

Không cần cấu hình thêm trong Runner. Khi Xcode build:

Scheme Pre-action ghi ios/.envfile
    ↓
CocoaPods Generate Native Build Env chạy before_compile
    ↓
Swift generator đọc đúng .env
    ↓
DERIVED_FILE_DIR/NativeBuildEnv.generated.h
    ↓
NativeBuildEnvSdkPlugin.m compile payload vào pod binary
    ↓
NativeBuildEnv.initialize() nhận map qua MethodChannel

Generated header chỉ nằm trong Xcode DerivedData, không sửa pub cache hoặc source của app. Pod script không log env values.

Pod target được cấu hình ENABLE_USER_SCRIPT_SANDBOXING=NO để phase có thể đọc file env được chọn bên ngoài thư mục Pods. App Runner không cần tắt sandbox.

4.5. Thứ tự chọn env

Cả chế độ xcconfig và runtime header dùng cùng Swift generator và precedence:

NATIVE_BUILD_ENV_FILE environment variable
    ↓
ENVFILE environment variable
    ↓
ios/.envfile
    ↓
<Flutter root>/.envfile
    ↓
<Flutter root>/.env

Scheme Pre-action là cách khuyến nghị vì explicit và không phụ thuộc cách đặt tên Build Configuration.

Có thể khai báo optional build setting:

NATIVE_BUILD_ENV_REQUIRED_KEYS = APP_ENV,API_BASE_URL

Generator validate danh sách này khi setting có trong process environment. Dart nên tiếp tục validate runtime bằng NativeBuildEnv.initialize(requiredKeys:).

4.6. File generated cần ignore

Thêm vào .gitignore của app:

ios/.envfile
ios/Flutter/tmp.xcconfig

Không ignore .env theo mặc định nếu project chủ động version-control public configuration; nếu env chứa dữ liệu nội bộ, quản lý bằng CI hoặc cơ chế phù hợp.

5. Flutter

Khởi tạo trước runApp:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await NativeBuildEnv.initialize(
    requiredKeys: const ['APP_ENV', 'API_BASE_URL'],
  );

  runApp(const MyApp());
}

Đọc giá trị:

final environment = NativeBuildEnv.get('APP_ENV');
final apiBaseUrl = NativeBuildEnv.get('API_BASE_URL');
final logging = NativeBuildEnv.getBool('ENABLE_LOGGING', defaultValue: false);

Map được cache immutable. MethodChannel chỉ gọi lại khi truyền forceReload: true.

6. CLI độc lập

fvm dart run native_build_env_sdk:generate \
  --input .env.development \
  --output build/native_env/development.json \
  --required APP_ENV,API_BASE_URL

CLI fail khi file không tồn tại, assignment sai, key trùng hoặc thiếu required key. CLI không in env values.

Bảo mật

Mọi config compile vào APK/IPA đều có thể bị trích xuất. Base64 chỉ là encoding. Không đưa private API key, client secret, password, signing key, service account hoặc encryption key cần giữ bí mật vào plugin. Secret phải nằm trên backend hoặc secret manager; token runtime nên ngắn hạn và lưu bằng Keychain/Keystore.

Troubleshooting iOS

  • Nếu pod runtime không cập nhật sau khi nâng plugin: chạy lại pod install.
  • Nếu build báo thiếu .env: kiểm tra nội dung ios/.envfile và đường dẫn từ Flutter project root.
  • Nếu compile-time $(KEY) cũ nhưng runtime đúng: build lại để Xcode reload tmp.xcconfig.
  • Nếu script fail: xem phase Generate Native Build Env trong Pods target; log chỉ hiển thị tên file, không hiển thị values.
  • Thay đổi .env luôn yêu cầu build lại app.

English Documentation

Native Build Env SDK reads public configuration generated during native builds. It does not use --dart-define or add .env files to Flutter assets.

How it works

Android runtime:
.env -> Gradle flavor -> Android string resource -> Kotlin -> MethodChannel

iOS compile time:
Scheme Pre-action -> .envfile -> Swift generator -> Flutter/tmp.xcconfig

iOS runtime:
.envfile -> CocoaPods before_compile -> generated Objective-C header
         -> Objective-C plugin -> MethodChannel

The SDK does not protect secrets. Anything packaged in an APK or IPA can be extracted. Use it only for public configuration.

1. Add the dependency

dependencies:
  native_build_env_sdk: 0.1.0-beta.1

Then run:

fvm flutter pub get

2. Create environment files

Files may live anywhere. A common layout at the Flutter project root is:

.env.development
.env.staging
.env.production
APP_ENV=development
APP_NAME="Example Development"
API_BASE_URL=https://dev-api.example.com
ENABLE_LOGGING=true

Do not add these files to flutter.assets. The parser supports assignments, comments, single/double quotes, and the optional export prefix. Variable expansion is not supported.

3. Android integration

Place the SDK configuration after the Android/Flutter plugins are applied but before android { ... }. The script loads the env selected for the current build and exposes it as project.env, similar to flutter_config:

project.ext.nativeBuildEnvFiles = [
    development: ".env.development",
    staging    : ".env.staging",
    production : ".env.production",
]

project.ext.nativeBuildEnvDefaultFlavor = "development"
project.ext.nativeBuildEnvRequiredKeys = ["APP_ENV", "API_BASE_URL"]

apply from: project(':native_build_env_sdk').projectDir.getPath() +
    "/native_build_env.gradle"

nativeBuildEnvDefaultFlavor is used during Gradle sync or when a task does not contain a flavor name. A task such as assembleProductionRelease automatically selects .env.production. Override explicitly with -PnativeBuildEnvFlavor=production or the NATIVE_BUILD_ENV_FLAVOR environment variable.

Relative paths use the Flutter project root. Absolute paths and File values are supported. Every map key must match a productFlavor.

Read env in app/build.gradle

After apply from, read values directly:

def verName = project.env.get("VER_NAME")
if (verName == null || verName.isEmpty()) {
    verName = "1.0"
}

def verBuild = project.env.get("VER_BUILD")
if (verBuild == null || verBuild.isEmpty()) {
    verBuild = "1"
}

The host app decides where to use them; the plugin never overrides Flutter configuration:

android {
    defaultConfig {
        versionName verName
        versionCode verBuild.toInteger()
    }

    flavorDimensions "environment"
    productFlavors {
        development { dimension "environment" }
        staging { dimension "environment" }
        production { dimension "environment" }
    }
}

Example env values:

VER_NAME=1.2.3-dev
VER_BUILD=12301

To fall back to Flutter's version instead of literals:

def verName = project.env.get("VER_NAME") ?: flutter.versionName
def verBuild = (project.env.get("VER_BUILD")
        ?: flutter.versionCode.toString()).toInteger()

Because the host assigns the version, precedence is entirely controlled by app code; the SDK only reads and exposes string values.

Use an env value at Android compile time

Every env key automatically becomes a string resource without an allowlist:

KEY_NAME -> @string/native_build_env_key_name

Given APP_NAME="Example Development", the plugin generates:

@string/native_build_env_app_name

Use it in android/app/src/main/AndroidManifest.xml:

<application
    android:label="@string/native_build_env_app_name"
    android:name="${applicationName}"
    android:icon="@mipmap/ic_launcher">
    <!-- ... -->
</application>

The plugin still serializes the complete map as Base64 JSON for Flutter runtime. If keys differing only by letter case produce the same Android resource name, the build fails.

The legacy ext.nativeBuildEnv structure remains supported.

fvm flutter run --flavor development
fvm flutter build appbundle --flavor production

4. iOS integration

Each iOS flavor maps to an Xcode Scheme. One .envfile drives two independent pipelines:

  • The Scheme Pre-action generates tmp.xcconfig for Xcode and Info.plist.
  • A CocoaPods script phase generates runtime configuration before compilation.

No Runner Build Phase, JSON resource, or Info.plist payload is required.

4.1. Update CocoaPods

After adding or upgrading the plugin, run pod install in the ios directory. The podspec installs a Generate Native Build Env phase on the pod target at before_compile.

4.2. Select an env file in each Scheme

Open:

Product > Scheme > Edit Scheme > Build > Pre-actions

Select Runner under Provide build settings from. For the development scheme, add:

set -eu

echo ".env.development" > "${SRCROOT}/.envfile"

/bin/sh \
  "${SRCROOT}/.symlinks/plugins/native_build_env_sdk/ios/Scripts/RunNativeBuildEnvGenerator.sh" \
  --ios-root "${SRCROOT}" \
  --format xcconfig \
  --output "${SRCROOT}/Flutter/tmp.xcconfig"

Every Scheme must contain the complete Pre-action so both .envfile and tmp.xcconfig are regenerated. For the production Scheme, use the same script with a different selected file:

set -eu

echo ".env.production" > "${SRCROOT}/.envfile"

/bin/sh \
  "${SRCROOT}/.symlinks/plugins/native_build_env_sdk/ios/Scripts/RunNativeBuildEnvGenerator.sh" \
  --ios-root "${SRCROOT}" \
  --format xcconfig \
  --output "${SRCROOT}/Flutter/tmp.xcconfig"

The wrapper removes iOS SDK variables inherited from Xcode before invoking Swift with the macOS SDK. This prevents Scheme Pre-actions from mixing an iPhoneSimulator sysroot with a macOS target.

.envfile and tmp.xcconfig are shared by all Schemes; the Pre-action of the Scheme being built overwrites them with the corresponding environment. .envfile stores only the selected name or path, not environment values. Relative paths resolve from the Flutter project root; absolute paths are supported.

4.3. Use env at compile time

Append this to Runner xcconfig files such as Debug.xcconfig and Release.xcconfig:

#include? "tmp.xcconfig"

Values then become Xcode Build Settings. For example, in Info.plist:

<key>CFBundleDisplayName</key>
<string>$(APP_NAME)</string>

The generator escapes URL separators so xcconfig does not interpret // as a comment. Xcode may load xcconfig before the Pre-action during the first clean build; build once more if a compile-time value has not refreshed. Runtime is not affected because the pod phase reads .envfile after the Pre-action.

4.4. Automatic runtime configuration

The host app needs no additional Runner configuration:

Scheme Pre-action writes ios/.envfile
    ↓
CocoaPods Generate Native Build Env runs before_compile
    ↓
Swift generator reads the selected .env
    ↓
DERIVED_FILE_DIR/NativeBuildEnv.generated.h
    ↓
NativeBuildEnvSdkPlugin.m compiles the payload into the pod binary
    ↓
NativeBuildEnv.initialize() receives the map through MethodChannel

The generated header stays in DerivedData; the script does not modify the pub cache or host source and never logs values. Script sandboxing is disabled only for the pod target so it can read the selected file outside Pods. Runner does not need this setting.

4.5. Env selection precedence

Both output modes use the same Swift generator and precedence:

NATIVE_BUILD_ENV_FILE environment variable
    ↓
ENVFILE environment variable
    ↓
ios/.envfile
    ↓
<Flutter root>/.envfile
    ↓
<Flutter root>/.env

The Scheme Pre-action is recommended because it is explicit and independent of Build Configuration naming. Optional validation can be configured with:

NATIVE_BUILD_ENV_REQUIRED_KEYS = APP_ENV,API_BASE_URL

The generator validates it when available in the process environment. Continue to validate runtime values through NativeBuildEnv.initialize(requiredKeys:).

4.6. Ignore generated files

ios/.envfile
ios/Flutter/tmp.xcconfig

5. Flutter integration

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await NativeBuildEnv.initialize(
    requiredKeys: const ['APP_ENV', 'API_BASE_URL'],
  );

  runApp(const MyApp());
}
final environment = NativeBuildEnv.get('APP_ENV');
final apiBaseUrl = NativeBuildEnv.get('API_BASE_URL');
final logging = NativeBuildEnv.getBool('ENABLE_LOGGING', defaultValue: false);

Values are cached in an immutable map. The MethodChannel reloads only when forceReload: true is requested.

6. Standalone CLI

fvm dart run native_build_env_sdk:generate \
  --input .env.development \
  --output build/native_env/development.json \
  --required APP_ENV,API_BASE_URL

The CLI fails on missing files, invalid assignments, duplicate keys, or missing required keys. It never logs values.

Security

Every value compiled into an APK or IPA can be extracted; Base64 is encoding, not encryption. Never embed private API keys, client secrets, passwords, signing keys, service-account credentials, or encryption keys that must remain secret. Keep secrets on a backend or in a secret manager, and use short-lived runtime tokens stored in Keychain or Keystore.

iOS troubleshooting

  • Run pod install again after upgrading the plugin so the podspec phase is refreshed.
  • If runtime generation reports a missing file, check ios/.envfile and resolve the selected path from the Flutter project root.
  • If a compile-time $(KEY) is stale while runtime is correct, build again so Xcode reloads tmp.xcconfig.
  • Inspect Generate Native Build Env in the Pods target when generation fails; logs include only the file name, never values.
  • Changing .env always requires rebuilding the app.