ejenix_flutter 0.1.1
ejenix_flutter: ^0.1.1 copied to clipboard
Official Ejenix Flutter SDK: LiveOps remote config, feature flags, signed server-driven UI, asset verification, and code-push status wrappers.
The official Flutter SDK for the Ejenix LiveOps platform: remote config, feature flags, server-driven UI, signed asset/config delivery, and code-push status wrappers — all behind one signed, cache-backed client.
Features #
- Signed bootstrap.
Ejenix.initializefetches a signed LiveOps payload; a tampered or unsigned payload is rejected, and the SDK falls back to the last-known-good cache when the backend is unreachable. - Remote config & feature flags with typed getters and synchronous fallbacks, so your UI always has a value to render.
- Server-driven UI widgets —
LiveText,LiveImage,RemoteConfigBuilder,FeatureFlagBuilder, andLiveScreenRenderer. - Code-push wrappers over the native updater, including boot-loop guard hooks and rollback to base.
- Analytics via a batched event queue (
Ejenix.events).
Getting started #
ejenix init adds the dependency, raises the Android minSdk the SDK
requires, and wires the boot hook a staged patch needs. Run it in your app:
ejenix init --sdk-key sdk_… --app-key app_… --public-key <your publisher key>
flutter pub get
How the dependency resolves #
init adds a version range:
ejenix_flutter: ^0.1.0
^0.1.0 resolves as >=0.1.0 <0.2.0. Under Dart's versioning a 0.x MINOR
bump is the breaking one, so that is the compatible range, and
flutter pub upgrade ejenix_flutter moves you inside it.
You can also add it without the CLI:
flutter pub add ejenix_flutter
Usage #
Initialize once at startup, then read config and flags anywhere:
import 'package:ejenix_flutter/ejenix_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Ejenix.initialize(sdkKey: 'sdk_live_xxx');
runApp(const MyApp());
}
The SDK key is the only thing it needs. Your app version, build number, platform and a stable per-install id come from the device itself, so you cannot get them wrong — and the app version and build number are what decide which release a patch is matched against, so a typed-in value would silently target the wrong build.
Everything else is an override for when you have a reason:
await Ejenix.initialize(
sdkKey: 'sdk_live_xxx',
// Verify the payload yourself — see "Payload verification" below. Do NOT
// point a verifier at the Ejenix control plane: it signs with a secret it
// does not hand out, so every payload would be rejected.
// Point at a different control plane. Defaults to the production one.
apiBaseUrl: Uri.parse('https://api.ejenix.com'),
// Identify the user for targeting. Optional.
userId: 'user-123',
traits: {'plan': 'pro'},
);
Read remote config and feature flags with a guaranteed fallback:
final title = Ejenix.config.getString('home.title', fallback: 'Welcome');
final showCheckout =
await Ejenix.flags.boolValue('new_checkout', defaultValue: false);
Ejenix.events.track('screen_view', payload: {'screen': 'home'});
Render server-driven UI:
LiveText('home.title', fallback: 'Welcome');
FeatureFlagBuilder(
flagKey: 'new_checkout',
defaultValue: false,
builder: (context, enabled) =>
enabled ? const NewCheckout() : const LegacyCheckout(),
);
Android: initialize the updater before Flutter starts #
A staged patch is applied at BOOT, before any Dart runs, so the updater has to exist before the Flutter engine does. An app that only initializes the updater from Dart has already answered "no patch" by the time it matters.
So an Android app calls EjenixBoot.init from Application.onCreate:
class MyApplication : Application() {
override fun onCreate() {
val storageDir = File(filesDir, "engento").apply { mkdirs() }
EjenixBoot.init(
JSONObject()
.put("app_id", "app_…") // the app key from the console
.put("environment", "production")
.put("release_version", "1.2.0") // your versionName
.put("platform", "android")
.put("architecture", "arm64")
.put("channel", "stable")
.put("storage_dir", storageDir.absolutePath)
.put("patch_check_url", "https://api.ejenix.com/v1/patch/check")
.put("public_verification_key", "…") // the same key you pass to Dart
.put("sdk_key", "sdk_…")
.toString()
)
super.onCreate()
}
}
ejenix init --sdk-key <k> --app-key <a> --public-key <p> writes all of this
for you — the Application name and the three meta-data values — and the SDK
ships dev.ejenix.flutter.EjenixApplication, so you only need your own
class if you already have one. If you do, extend ours or call
EjenixApplication().initializeEjenix() from your onCreate; init will
not replace an Application you chose.
The version, the ABI and the storage directory are all derived on device, so none of them can be typed in wrong. The storage directory in particular is reported by the plugin to the Dart side as well, so the two halves cannot stage into different places — which is the next thing that silently breaks this, because both halves succeed and the patch simply never applies.
Written by hand, you add the Application subclass above and name it in
android/app/src/main/AndroidManifest.xml:
<application android:name=".MyApplication" …>
storage_dir must be the SAME directory you pass as
EjenixCodePushOptions.storageDirectory — getApplicationSupportDirectory()
on Android resolves to filesDir, so File(filesDir, "engento") matches
'${(await getApplicationSupportDirectory()).path}/engento'. Two different
directories means Dart stages a patch where boot never looks.
init never throws and returns a status code; 0 is success, -2 means this
build carries no Ejenix engine. A failure means the app launches on its base
release, which is the correct outcome.
Do not skip this step. Without it a patch downloads, verifies and stages forever and never runs — and nothing reports an error, because nothing has gone wrong except that the updater was asked too late.
iOS: initialize the updater before Flutter starts #
The same requirement, for the same reason. The patch is applied inside
super.application(_:didFinishLaunchingWithOptions:), where the engine is
created — so an app that initializes the updater from Dart has already
answered "no patch" before Dart runs.
ejenix init writes both halves. The SDK ships EjenixBoot, so your
AppDelegate needs one line, before super:
import Flutter
import ejenix_flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
EjenixBoot.start()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
and the three values only you know, in ios/Runner/Info.plist:
<key>EjenixSdkKey</key> <string>sdk_…</string>
<key>EjenixAppKey</key> <string>app_…</string>
<key>EjenixPublicKey</key> <string>…</string>
EjenixBoot.start() also arms the boot-loop guard, which is what stands
between a patch that crashes during startup and an app that applies it again on
every launch forever. It derives everything else: the release version from the
bundle, the storage directory from Application Support — the same path
getApplicationSupportDirectory() gives Dart, so the two halves cannot look in
different places.
Run cd ios && pod install after ejenix init. CocoaPods caches each pod's
file list, so the first build without it fails with Cannot find 'EjenixBoot' in scope, which reads as though the line above is wrong.
start() never throws and returns a status code; 0 is success, -3 means the
Info.plist keys are missing. A failure means the app launches on its base
release, which is the correct outcome.
Do not skip this step, and do not skip the boot-loop guard with it. Without the hook a patch stages forever and never runs; without the guard, a patch that crashes during startup is applied again on every launch.
Turning code push on #
Code push stays OFF until you configure it. Both options below are required:
import 'package:path_provider/path_provider.dart'; // for the line below
codePush: EjenixCodePushOptions(
storageDirectory: (await getApplicationSupportDirectory()).path,
publicVerificationKey: '<your publisher public key>',
updateMode: EjenixUpdateMode.automatic,
),
getApplicationSupportDirectory() comes from
path_provider, which is yours to
add — flutter pub add path_provider. This SDK does not depend on it, because
the directory is your application's to choose; it is named here because it is
the one the Android side's storage_dir must match, and because a reader who
copies the block above without it gets an undefined function.
Without them the SDK logs, unconditionally:
[Ejenix] native updater NOT started: EjenixCodePushOptions.storageDirectory
and EjenixCodePushOptions.publicVerificationKey are not set, so patches will
be checked for and never applied.
and the reason is readable at runtime:
final why = Ejenix.codePush.nativeSkipReason; // null when running
final patch = await Ejenix.codePush.currentPatch();
print(patch.reason); // the same reason
publicVerificationKey is the public half of the key your patches are signed
with. Every patch is verified against it on device before anything is applied,
so a patch signed by anyone else is refused — which is why there is no default.
When a check actually happens #
Three moments, and they are not equally reliable — the difference is worth knowing before you rely on one.
- On launch, inside
Ejenix.initialize. Always. - On return to the foreground. Always. An app the user reopens sees a patch within seconds of reopening.
- In the background, once the updater is configured, the SDK asks the
platform to check periodically. The platform decides whether and when to
run it, and both platforms treat the interval as a floor rather than a
promise:
-
Android uses WorkManager, which enforces a minimum of 15 minutes and decides when a constrained job actually runs.
-
iOS submits a
BGAppRefreshTasktoBGTaskScheduler, which gives no guarantee at all and may never run it. It also needsUIBackgroundModesin yourInfo.plist, whichejenix initdoes not add — a background mode has to be justified in App Store review, and that is your submission to defend:<key>UIBackgroundModes</key> <array><string>fetch</string><string>processing</string></array>
-
So: do not design around the background check. An app that is opened sees patches promptly; an app that is never opened may not see one at all, and on iOS that is the platform's decision rather than ours.
A patch is also bound to the exact binary it was built against; delivering one
to a different build is refused as old_artifact_sha_mismatch rather than
applied.
Offline safety #
Ejenix.initialize persists the payload through the configured
EjenixCacheStore (EjenixFileCacheStore by default on device). If a later
fetch fails, the SDK keeps serving the last values it stored instead of
falling back to your compile-time defaults — so an app that starts without a
network behaves like one that started with it.
Payload verification #
Read this before configuring a verifier. It is easy to make the SDK worse by configuring the wrong one.
By default no verifier is configured, the payload is trusted on TLS alone, and the SDK says so once at startup. That is the state the quick start above puts you in.
signatureVerifier takes anything implementing EjenixSignatureVerifier, and
a configured verifier that REJECTS a payload means nothing from the control
plane is used at all — every flag and config read answers with your
compile-time default. The SDK prints that unconditionally and exposes it as
Ejenix.instance.signatureRejection, because otherwise it looks like every
feature is simply switched off.
Do not point a verifier at the Ejenix control plane yet #
Ejenix signs the bootstrap payload with HMAC-SHA256 using a server-side secret that is not distributed. There is no key you can put in your app that will match it, so any verifier aimed at our bootstrap rejects every payload and leaves your app running on defaults. Asymmetric signing from Ejenix, with a public key you can embed, is not shipped yet.
If you sign payloads yourself #
Running your own or a proxied control plane, use EjenixEd25519Verifier:
signatureVerifier: EjenixEd25519Verifier('<base64 Ed25519 public key>'),
It verifies Ed25519 over the exact bytes of jsonEncode(payload) with the
signature field removed, and the device holds only the public half.
EjenixHmacSha256Verifier exists for a symmetric setup, and the trade-off is
worth stating plainly: HMAC's verifying key IS its signing key, so an app that
can verify is an app carrying the key that signs. Anyone who unzips your app
can extract it and produce a payload that verifies. Prefer Ed25519 for
anything you distribute.
Patches are different, and stronger #
Code push does not use any of the above. A patch is verified on device against
EjenixCodePushOptions.publicVerificationKey with Ed25519 — only the public
half is ever on the device — so a patch signed by anyone else is refused no
matter what an attacker extracts from your app.
Platform support #
| Platform | Minimum | Status |
|---|---|---|
| Android | API 28 (Android 9) | ✅ Plugin, remote config, flags, SDUI, assets, code push |
| iOS | iOS 13 | ✅ Plugin, remote config, flags, SDUI, assets, code push |
The Android minimum is not optional #
android/build.gradle declares minSdk = 28. A default Flutter app is lower,
and Gradle's manifest merger refuses the build outright:
uses-sdk:minSdkVersion 24 cannot be smaller than version 28 declared in
library [:ejenix_flutter]
ejenix init raises your android/app/build.gradle[.kts] to 28 and tells you
it did, because adding the dependency without it leaves a project that cannot
compile. Raising it means devices below Android 9 can no longer install the
app; that is a real trade and the reason the CLI says so out loud rather than
editing quietly.
What this package covers #
- The licence is BSD 3-Clause. See LICENSE.
- The native updater bridge covers Android and iOS, which are the two platforms code push is available on. macOS, Web, Windows and Linux have no native bridge. The four pure-Dart features — remote config, feature flags, server-driven UI and asset verification — need no native bridge and run anywhere Flutter does, macOS included.
License #
BSD 3-Clause — © 2026 Ejenix. See LICENSE.