Engage Flutter SDK
The official Flutter bridge for the Engage Android and iOS SDKs. It delegates storage, synchronization, push, in-app evaluation and Inbox behavior to the native SDKs; Dart exposes one idiomatic API and multicast state streams.
Installation
Install the published package from pub.flutter-io.cn:
flutter pub add engage_flutter
The plugin pins independently compatible versions of the Android modules and the iOS Swift package. A Flutter package update does not implicitly request native artifacts with the same version number. Applications do not need to copy native SDK source code into their project.
Android repository
Downloading engage_flutter from pub.flutter-io.cn installs the Dart package and the Flutter bridge. The
bridge still declares five native Android dependencies published by the engage-android monorepo
on JitPack. Gradle repositories are controlled by the consuming application and are not inherited
from a library, so every Android host must make JitPack available explicitly.
For a standard Flutter Android project, add the repository to android/build.gradle.kts:
allprojects {
repositories {
google()
mavenCentral()
maven("https://jitpack.io") {
content { includeGroup("com.github.mathias8dev.engage-android") }
}
}
}
If the application centralizes repositories with RepositoriesMode.FAIL_ON_PROJECT_REPOS, put the
same JitPack declaration inside dependencyResolutionManagement.repositories in
android/settings.gradle.kts instead. Without it, Gradle reports that the
com.github.mathias8dev.engage-android modules cannot be found even though the Flutter package was
downloaded successfully from pub.flutter-io.cn.
Requirements
- Flutter 3.41 or newer
- Android API 24 or newer, Java 17 and core library desugaring
- iOS 15 or newer
- Flutter Swift Package Manager support enabled for iOS
// android/app/build.gradle.kts
android {
defaultConfig { minSdk = 24 }
compileOptions { isCoreLibraryDesugaringEnabled = true }
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}
Start
await Engage.start(
config: const EngageConfig(
appKey: String.fromEnvironment('ENGAGE_APP_KEY'),
logLevel: EngageLogLevel.verbose,
),
);
EngageLogLevel.info is the default. Use verbose locally to trace Dart API calls, native bridge
traffic, module lifecycle, persistence, network requests and state transitions. Dart records use
the Engage logger name; Android records use Logcat tag Engage; iOS records use subsystem
io.engage.sdk and category Engage. Technical IDs remain visible, while App keys, tokens,
binding codes, attribute values and payload values are never logged.
Engage.start creates the installation and activates the native modules. A
second call with the same configuration is safe; the native SDK rejects a
different App identity in the same process.
On Android, the first successful call also persists the validated native
startup configuration. On later process starts, an Android initialization
provider restores that configuration and starts the native SDK before a
Flutter engine or Dart isolate exists. This lets FCM delivery, notification
display and native event persistence work during a cold background launch.
The persisted configuration is invalidated when the installed App build
changes, so the updated App must reach Engage.start once before this native
cold-start path becomes active again.
Installation and profile
The SDK never receives an App user ID or an App authentication token. The App backend associates the installation using the opaque binding code:
final bindingCode = await Engage.installation.issueBindingCode();
await appBackend.associateEngageInstallation(bindingCode);
The current installation ID is replayed to every listener:
final subscription = Engage.installation.id.listen((installationId) {
diagnostics.recordInstallation(installationId);
});
Engage calls this value installationId; it does not expose a separate Airship-style channelId.
For push readiness, also inspect Engage.push.status.value: tokenRegistered must become true
in addition to the installation being present.
Attributes, tags and subscriptions use typed editors:
await Engage.installation.editAttributes((attributes) {
attributes.set('store_id', 'paris-12');
attributes.remove('legacy_attribute');
});
await Engage.profile.editTags((tags) {
tags.add('premium');
});
await Engage.profile.editSubscriptions((subscriptions) {
subscriptions.subscribe('marketing', {Channel.push, Channel.email});
});
Events and screens
await Engage.events.track('order_completed', (event) {
event.transactionId = 'order-456';
event.value = 199.90;
event.put('currency', 'EUR');
});
await Engage.events.trackScreen('checkout');
await Engage.events.clearScreen();
await Engage.events.flush();
The native outbox remains the durability boundary. A completed platform call means that the native SDK accepted the command; network convergence continues in the background.
Push
Engage never asks for the system notification permission. The Flutter App owns
that UX and uses the platform permission API of its choice. optIn and
optOut only change the Engage delivery preference.
await Engage.push.optIn();
await Engage.push.optOut();
Engage.push.status.listen((status) {
print('${status.permission} / ${status.subscription}');
});
Engage.push.events.listen((event) {
if (event case PushOpened(:final deepLink)) {
if (deepLink != null) navigator.open(deepLink);
}
});
DEEPLINK destinations are exposed through PushOpened.deepLink for the
Flutter router. WEB_URL destinations are opened automatically in the system
browser by the native Engage SDK; the open event is still emitted with a null
deepLink, preventing duplicate navigation in Dart.
Android resources are declared by name because Dart cannot reference a host
App's generated R class. Every name is resolved and validated by the Android
bridge before the native SDK starts:
const PushConfig(
foregroundPresentation: ForegroundPresentation.show,
android: AndroidPushConfig(
smallIconResource: 'ic_notification',
accentColorResource: 'notification_accent',
defaultChannelKey: 'general',
channels: [
AndroidPushChannel(
key: 'general',
nameResource: 'channel_general',
descriptionResource: 'channel_general_description',
showBadge: true,
),
AndroidPushChannel(
key: 'transactional',
nameResource: 'channel_transactional',
importance: NotificationImportance.high,
sound: AndroidResourcePushSound('transactional_notification'),
),
],
),
)
iOS notification categories use their own platform model. APNs registration callbacks are forwarded by the Flutter plugin lifecycle delegate; no App token or notification permission is requested by Engage.
const PushConfig(
ios: IosPushConfig(
categories: [
IosPushCategory(
key: 'order',
actions: [
IosPushAction(key: 'open_order', title: 'Open order', foreground: true),
],
),
],
),
)
Rich notifications need the usual iOS Notification Service Extension. Add the
native SDK's EngagePushServiceExtension Swift Package product to that App
Extension target and use its base class:
import EngagePushServiceExtension
final class NotificationService: EngageNotificationServiceExtension {}
Android rich images are handled by the native FCM module. If image download fails, both platforms still deliver the standard notification without the attachment.
In-app experiences
Overlays are rendered automatically by the native SDK. Flutter may pause them or decide per candidate:
await Engage.inApp.overlays.pause();
Engage.inApp.overlays.displayDelegate = (candidate) {
return isPaymentVisible ? DisplayDecision.defer : DisplayDecision.allow;
};
await Engage.inApp.overlays.resume();
Embedded content uses the native renderer through a Platform View. Flutter
requires an explicit layout constraint, so the placement declares its slot
height. It collapses when no content is eligible unless a placeholder is
provided or the published presentation uses reserveSpace.
const EngageInAppPlacement(
placementKey: 'home.hero',
height: 180,
);
For a custom renderer, observe the headless content instead:
Engage.inApp.placement('home.hero').listen(renderCustomExperience);
Actions
final registration = Engage.actions.register('open_order', (action) async {
navigator.openOrder(action.arguments.requireString('order_id'));
return ActionResult.completed;
});
await registration.cancel();
Actions from push, in-app content and the Engage Message Center use the same registry.
On Android, registered Dart action names are persisted alongside the native startup configuration. If an action or push event arrives while no Flutter engine is attached, the native bridge stores it durably and delivers it when Dart registers the action or subscribes to push events. Delivery is removed from that queue only after the Dart callback or event sink accepts it. The bridge retains at most 64 pending action executions and 32 pending push events; if either bound is exceeded, it evicts the oldest entry of that queue.
Preference Center
await Engage.preferenceCenter.display();
Engage.preferenceCenter.center('mobile-notifications').listen((snapshot) {
customPreferences.render(snapshot);
});
The ready-made UI and custom UI read the same native projection. Updates still
go through Engage.profile.editSubscriptions and
Engage.installation.editSubscriptions.
Message Center
Inbox entries are headless application data. There is no presentation model
and no intermediate Message or Payload wrapper:
final pager = Engage.messageCenter.inbox.pager(pageSize: 20);
final subscription = pager.state.listen((state) {
for (final entry in state.entries) {
renderByContract(entry.key, entry.payload);
}
});
await pager.refresh();
await pager.loadNextPage();
await Engage.messageCenter.inbox.markRead(InboxEntryId('entry-id'));
await pager.close();
await subscription.cancel();
Each pager owns an independent window. Its EngageState is hot, multicast and
replays the latest state without starting one fetch per listener. Unread state
is shared:
Engage.messageCenter.inbox.unreadCount.listen(updateBadge);
await Engage.messageCenter.display(); // Optional Engage UI rendered with DivKit.
Feature flags
Flutter platform calls are asynchronous, so flag getters return Future<T>.
Evaluation itself still happens synchronously against the native SDK's local
snapshot and never waits for a network response.
final checkoutV2 = await Engage.flags.getBoolean(
'checkout_v2',
defaultValue: false,
);
final configuration = await Engage.flags.getJson<CheckoutConfiguration>(
'checkout_configuration',
defaultValue: CheckoutConfiguration.defaultValue,
encode: (value) => value.toJson(),
decode: CheckoutConfiguration.fromJson,
);
Exposure deduplication, audiences, revision activation and disk caching remain owned by the native SDKs.
Features and privacy
await Engage.sdkFeatures.edit((features) {
features.enable(SdkFeature.push);
features.disable(SdkFeature.analytics);
});
await Engage.privacy.optOut();
await Engage.privacy.optIn();
await Engage.privacy.optOutAndWipe();
privacy.state, sdkFeatures.enabled, push.status, preference centers,
placements, unread count and pager states are all replaying multicast
EngageState streams. Push events are non-replaying broadcast events.
See Architecture for the bridge boundaries and native ownership rules, and Contract coverage for the complete mapping to the mobile API.