bzengage_flutter_sdk 0.7.2
bzengage_flutter_sdk: ^0.7.2 copied to clipboard
Official BzEngage Flutter SDK. A thin Dart layer that forwards calls to the native BzEngage Android and iOS SDKs (in-app messages, zones, push, events, consent, commerce).
Changelog #
0.7.2 — 2026-11-05 #
- Uses the Android SDK 0.1.6 (
com.bzengage:bzengage-android-sdk:0.1.6) and the iOS SDKBzEngage0.1.5. WithsetInAppDebug(true), the in-app debug log now gives the actual reason a message was not shown: already shown in this session, a layout the device cannot display, or another in-app message took the slot. Display behaviour is unchanged. - iOS: the app's
Podfileline becomespod 'BzEngage', :podspec => 'https://bzengage.com/sdk/ios/0.1.5/BzEngage.podspec'.
0.7.1 — 2026-11-02 #
- Uses the Android SDK 0.1.5 (
com.bzengage:bzengage-android-sdk:0.1.5). Events queued while offline are no longer sent twice (or, rarely, lost) when a push notification arrives while the app is running. The iOS SDK stays atBzEngage0.1.4 — the iOS side was not affected. - README: on Android, a notification tapped after only the app's screen was closed (the app process
kept running) delivers its link to
onPushDeeplink, not toinitialPushDeeplink(); links waiting for a listener have no time limit, and the 60-second limit applies only toinitialPushDeeplink().
0.7.0 — 2026-10-22 #
The SDK is now a thin layer over the native BzEngage SDKs #
This package no longer runs its own Dart implementation. Every call is forwarded to the
native BzEngage Android SDK (com.bzengage:bzengage-android-sdk:0.1.4) and iOS SDK
(BzEngage 0.1.4), the same way the React Native SDK works. Targeting, frequency,
storage, networking, in-app rendering, push display and rules all run natively, so a
Flutter app now behaves like a native app.
This is a breaking release. See the README in this package for setup.
Setup changes
- iOS: add
pod 'BzEngage', :podspec => 'https://bzengage.com/sdk/ios/0.1.4/BzEngage.podspec'andplatform :ios, '15.0'toios/Podfile. The notification service extension now uses the iOS SDK's class:import BzEngage/class NotificationService: BzEngageNotificationService {}. - Android: the plugin adds the BzEngage Maven repository. Apps whose
settings.gradleforbids project repositories must addhttps://bzengage.com/sdk/androidthemselves. - The package no longer depends on
http,shared_preferences,firebase_core,firebase_messaging,flutter_local_notifications,app_settings,webview_flutter,url_launcherorflutter_js. If your app usesfirebase_messagingon Android, forward BzEngage pushes withBzEngageSDK.handlePushMessage()(README §11.6). - Data stored by 0.6.x on the device (anonymous ID, signed-in user, consent, attribution, frequency counters, offline events, seen orders) is carried over to the native SDK the first time 0.7.0 starts, so an updated app keeps the same user.
API changes (0.6.x → 0.7.0)
| 0.6.x | 0.7.0 |
|---|---|
BzEngageSDK(baseUrl:, apiKey:, personId:, distinctId:, platform:, projectId:) |
await BzEngageSDK.initialize(baseUrl:, apiKey:, inAppAllowedSchemes:, inAppDebug:, linkHandling:) |
sdk.personId |
await sdk.currentPersonId() |
identify(id, properties:, anonDistinctId:) → int? |
identify(id, properties:) → Future<void> that completes after the server answered — read the person with await sdk.currentPersonId() right after. It never throws; on failure the person is unchanged |
loadPersistedIdentity() → int? |
loadPersistedIdentity() → void (no longer needed — restored automatically) |
setCurrentScreen(name) |
setScreen(name) |
setRuleUserProperty(key, value) · setUserProps(map) |
setUserProperty(key, value) |
appContext |
await sdk.getContext() |
showAllMessages(context, onDeeplink:, onCustomEvent:) · showNextMessage(...) |
await sdk.showMessages() + streams onInAppDeeplink · onInAppUrlOpen · onInAppCustomAction — throws ErrorCodes.noActivity while the app is in the background (Android and iOS) |
onPushDeeplink callback |
onPushDeeplink stream + await sdk.initialPushDeeplink() |
onCustomAction |
onInAppCustomAction stream |
| commerce snapshots (synchronous) | await sdk.commerce.cartSnapshot() etc. |
BzenZone(name:, sdk:, placeholder:, padding:, onCtaTap:, maxHeight:) |
BzenZone(name:, maxHeight:, placeholder:, padding:, onHeightChanged:) — zone links arrive on the in-app streams |
registerForPush() |
reRegisterPushToken() |
sdk.commerce (CommerceApi) |
sdk.commerce (BzCommerce) — same functions; snapshots are now Future |
setContext · updateContext · setCurrentScreen · setUserProps (synchronous) |
setContext · updateContext · setScreen · setUserProperty return Future<void> |
New: markOpenedFrom, callSystemRule, setInAppDebug, reRegisterPushToken,
BzEngageSDK.handlePushMessage, BzEngageSDK.isBzEngagePush, LinkHandling,
BzEngageError / ErrorCodes.
Removed: initCustomRules, syncCustomRules, syncTriggerTemplates,
enablePushAutoTracking, setPosthogApiKey, flushRuleEvents, runSystemRules,
evaluateCampaignTriggers, trackRuleEvent, resolveProperties, repairAnonIdentity,
capturePushLanding, fetchCampaigns, applyFrequencyGate, showMessage,
fetchMessages, onEventTracked, ConsentWho, the debug… helpers, the public fields
(api, ruleRuntime, ruleRepo, templateRepo, goalMemo, couponManager,
triggerRuntime, channelTargetingRuntime, frequencyGuard, appOpenTracker,
pushService, baseUrl, apiKey, distinctId, platform), Flutter web support, and the
exported internal classes (BzEngageApi, BzAttribution, the anon_id helpers such as ensureAnonId, AppOpenTracker, the
rule engine, in-app renderer and model classes). The native SDKs do this work now.
License: the SDK is licensed to BzEngage customers for use with the BzEngage service (see LICENSE). Earlier versions said MIT.
Changes in the 0.6.x line before the switch #
The entries below describe fixes made to the Dart implementation after 0.6.0 and are kept for history. In 0.7.0 that Dart code is replaced by the native SDKs.
iPhone notification buttons now appear #
A BzEngage push with action buttons used to show no buttons on iPhone. iOS only
shows buttons for a registered notification category, and nothing registered one.
BzEngageNotificationService now registers a category from the push's buttons
(up to 3) before the notification is shown. Tapping a button opens the app and
goes to that button's link, as on Android.
- The extension now runs for every BzEngage push, not only pushes with an image. The server now marks every iPhone push so the extension runs.
- Categories your app registered are kept.
- Pushes from other providers get no BzEngage buttons.
willDeliver(content:request:)now runs for every push the extension handles, including pushes without an image. It no longer runs after the 30-second limit has passed.- If your app registers its own categories, read the current ones with
getNotificationCategoriesand add yours to them. Replacing the whole list removes the buttons from BzEngage notifications already on screen.
iPhone push no longer needs Firebase — and now actually arrives #
On iOS the SDK used to register the FCM token with platform: 'apns'. The
server sends apns tokens straight to Apple, so Apple rejected every one and the
server switched the token off: no BzEngage push ever reached an iPhone. It
also called Firebase.initializeApp() on iOS, which stops an app that has no
GoogleService-Info.plist.
On iOS the SDK now uses its own native code (ios/Classes/BzenPushHub.swift) for
the notification permission, the raw APNs token, the foreground banner and taps.
Nothing changes on Android.
initPush(),registerForPush()andenablePushAutoTracking()work on iOS with no Firebase setup. The APNs entitlement (Push Notifications capability) is still required.- A BzEngage push that arrives while the app is open is now shown as a banner. Before this change nothing appeared.
- Taps work while the app is open, in the background, or closed. Each tap opens
onPushDeeplinkand records the click and the landing once. - Pushes from other providers are left alone. The SDK does not show them, open them or record them.
- If your app also uses Firebase on iOS for its own pushes, those still go to Firebase.
Apps with an image-push extension failed to build. The new iOS push code
calls UIApplication.shared, which app extensions may not use, and the
Notification Service Extension links this package too. That code is now marked
unavailable in extensions, so the extension builds again. The README's
rich-push steps also missed two required steps, the extension's Podfile entry
and the build-phase order, and both are now documented.
The iOS build was broken. pubspec.yaml declared sharedDarwinSource: true,
so Flutter looked for the podspec in darwin/. It lives in ios/, so
pod install failed for every iOS app. The flag is removed.
A rule that keeps running too long is now taken out of service #
evaluateTrigger has always accepted a timeoutMs, but the value was never
read. Marketer-authored JavaScript ran with no limit of any kind.
Every rule door — evaluateTrigger, executeGoal, executeSystem and
evaluateRaw — now goes through one guard. It times the evaluation and, after
three consecutive overruns of the same rule, stops calling the engine for
that rule for the rest of the run. One run inside the limit clears the count, so
a single stall (a GC pause, a momentarily busy device) does not take a healthy
rule out of service.
Limits: 100ms for trigger, goal and system rules; 500ms for targeting template evaluation. Both match the Android SDK. A caller may pass its own value.
This is not a hard stop. An evaluation that has already begun cannot be
interrupted — flutter_js exposes no interrupt handler, and moving the engine to
an Isolate would not help either, because a Dart isolate blocked inside a
native call does not respond to kill. So a rule with a genuinely endless loop
still blocks the app the first time it runs. What the guard removes is paying
that cost again and again.
Three details keep this from misfiring:
- Overruns are only counted once per second. In-app candidates are filtered one message at a time, so the same rule can be evaluated several times in a single pass; without this, "three consecutive" would effectively mean "once".
- The count is kept per limit. The same rule is evaluated with a 100ms limit in one place and a 500ms one in another; sharing a counter would let the lenient place clear the strict place's strikes, and the rule would never reach three.
- Editing the rule brings it back. The counter is keyed by the rule's code, so once a marketer fixes a runaway rule and republishes it, the next sync runs it again — without that, the fix would not take effect until the app restarted.
A rule that has been taken out of service evaluates as false, so campaigns
gated on it stop being shown — deliberately, since letting them through would
deliver messages to people who do not match. Every decision is logged:
[bzen] rule=rule:12 stage=eval result=drop reason=quarantine_start elapsed=2409ms limit=100ms strike=3
[bzen] rule=rule:12 stage=eval result=drop reason=quarantined
Optional parameters were added to executeGoal, executeSystem, evaluateRaw
and the CustomRuleRuntime constructor. Existing calls are unaffected.
Anonymous events no longer lose their identity #
Events from a not-yet-identified user could go out with person_id: 0 and no
distinct_id at all. The server then substitutes the literal string
"unknown", which collapses every anonymous user in the project into a single
PostHog person and stops any delivery row from being written — push open and
click rates read as zero.
This happened whenever the one-time anonymous-id acquisition at startup failed (storage can be briefly unavailable while an app boots); it was never retried for the rest of that run.
The identity is now re-acquired at send time, for in-app and push lifecycle events, zone impressions and clicks, and for anything replayed from the offline queue. A logged-in user is never touched, and no value is ever invented — if storage stays unavailable the event goes out exactly as before.
Two members are added for that wiring. They are not meant to be called by host apps:
BzEngageSDK.repairAnonIdentity()— idempotent; returns the identity in use.BzEngageApi.anonIdentityRepair— the hook the SDK installs on the client.
No existing behaviour or signature changes.
0.6.0 — 2026-08-27 #
custom action buttons now reach your app #
A popup button can carry a custom action, meaning "the SDK cannot know what to
do here — the app decides". It never reached the app. The renderer had no
channel for it, so pressing such a button only showed a default toast. The
onCustomEvent parameter looked like the way to receive it, but nothing ever
called it.
Register one handler at startup and it fires from every display method:
sdk.onCustomAction = (value) {
if (value == 'add_to_cart') { /* your app decides */ }
};
showAllMessages and showNextMessage also accept onCustomEvent now, matching
onDeeplink. Previously only showMessage did — so the recommended path could
not pass one at all.
Resolution order: the per-call handler, then the registered one, then the existing toast. An app that registers nothing behaves exactly as before. A handler that throws is caught; the popup stays up. Button-tap tracking is unchanged and still fires first.
showNextMessage no longer repeats within a session #
showAllMessages skipped campaigns already shown in the current app session;
showNextMessage did not, so a campaign with no frequency limits reappeared on
every screen change. Both now behave the same.
This changes behaviour for apps calling showNextMessage — the same message
shows once per session instead of repeatedly. It also now sorts by priority and
accepts onDeeplink / onCustomEvent, matching showAllMessages; the added
parameters are optional and named, so existing calls compile unchanged.
logout() now clears the session record too. Without that, a campaign already
shown to one user stayed suppressed for whoever logged in next on the same
device.
Docs point at the path that enforces limits #
The class example recommended fetchMessages() + showMessage(), which
deliberately displays whatever it is handed — the per-user frequency limits added
in 0.5.0 are not applied there. An app following the example got none of them.
The example now uses showAllMessages, and every method that skips the limits
says so. The README gained a section on showing messages; it previously did not
mention it at all.
0.5.0 — 2026-08-26 #
In-app messages now respect their frequency limits on-device #
A campaign can carry three per-user limits — a lifetime cap, a per-day cap, and a minimum gap between showings. None of them did anything. The server has since been fixed to enforce all three at fetch time; this release adds the app-side half.
The app asks the server right before each display, so the server alone covers most cases. It cannot cover two:
- inside the server's 5-minute response cache window — the previously fetched list is reused
- before an impression reaches the server — the app counts at display time, the server counts when the event arrives
The SDK now keeps its own per-campaign tally and blocks in those two moments.
The app only ever blocks more, never less. A missing local tally (fresh install, new device) does not resurrect a message the server withheld. The other direction would reset every limit by reinstalling the app.
- Tallies are per person. Switching accounts on a shared device does not inherit the previous user's cap.
- Zone widgets are not counted. A zone banner is on screen the whole time it is mounted, so counting it would exhaust a popup's cap almost immediately.
- Limits are read only from
/inapp/messages. The samedisplay_ruleskey carries something else on/inapp/campaigns, so the shape is validated and anything unexpected is treated as "no limit". - A backwards device clock does not lock a campaign out. Negative elapsed time (booting before NTP sync, restoring a backup) is treated as "gap satisfied" rather than "gap never elapses".
- Campaigns without limits write nothing to storage.
New public API — DisplayRules, InAppFrequencyGuard,
FrequencyBlockReason, InAppMessage.displayRules, BzEngageSDK.frequencyGuard,
BzEngageSDK.applyFrequencyGate, BzEngageSDK.buildInAppEventHandler.
No host changes required. Upgrading is enough. showMessage() still displays
whatever it is handed — a host that picks a message explicitly is trusted — but
it now counts the impression.
Known limits. Tallies are per device, so multi-device totals are only accurate on the server. "Today" follows the device clock while the server follows the project timezone, so the two can disagree near midnight.
0.4.0 — 2026-08-18 #
App entries are now tracked automatically (app_open) #
App entry counts were always 0. On the web, posthog-js emits $pageview on
its own; mobile had no equivalent, and nothing in the SDK ever reported an entry.
The receiving side already existed — the server defines app_open as the mobile
counterpart of $pageview (session_start spec), and the targeting runtime
counts it for visitCount. Only the emitter was missing, which is why every
"visit count" targeting condition evaluated to 0.
app_open now fires on app entry — cold start and return from background — with
no host app changes. Upgrading is enough.
- 30-minute session gap. A brief exit (checking a notification) does not
count as a new entry; being away for 30+ minutes does. Matches GA4's default
and the
session_startsemantics the server already maps this event onto. The stamp is persisted, so killing and relaunching the app is judged by the same rule as a background return. entry_typeproperty —cold(process launch) orwarm(background resume), so launches and sessions can be separated in analysis later without a new SDK release.- Held back until identity is known.
personIdstarts at 0, andloadPersistedIdentity()is host-invoked — firing immediately would attribute entries to person 0. The entry is emitted onceidentify()orloadPersistedIdentity()resolves a person, and dropped if neither ever does. - Failures (network, storage) never propagate — analytics cannot break app startup or backgrounding.
Existing lifecycle observers are untouched; this one registers from the
constructor so it works regardless of which init* the host calls.
0.3.0 — 2026-08-12 #
Zone height — 500px cap removed (breaking-ish behaviour change) #
BzenZone measured its content height in the WebView but discarded any value
above 500px, leaving the 200px default. A 600px zone was therefore clipped to
200px with no error anywhere — and it looked fine in the admin editor, which made
it very hard to diagnose.
Root cause of the cap: the wrapper HTML had no doctype, so the WebView rendered
in quirks mode. There, a height:100% (common in content copied from popup
templates) resolves against the viewport even when the parent is auto — so the very
first measurement came back as the full screen height. The 500px cap was a guard
against that bogus value, and it took correct content down with it.
- Wrapper now starts with
<!DOCTYPE html>(standards mode) and forceshtml, body { height: auto; min-height: 0 }— aheight:100%zone now measures its content (21px in our test) instead of the screen (844px). - Height is measured with
getBoundingClientRect()and tracked byResizeObserver, so late-loading images and dynamic content are followed. Repeated identical values are no longer posted. - The cap is now the app's choice:
BzenZone(maxHeight: ...). Default is no limit — the zone takes its content height. Only absurd values (>10000px, i.e. a measurement error) are discarded.
Upgrade note: zones that were silently clipped to 200px will now take their real
height. If you relied on the clipping to bound your layout, pass maxHeight.
0.1.0 — 2026-05-14 (initial public release) #
Distribution model #
- Adopted industry-standard Free SDK + Paid Backend pattern (matches Braze, OneSignal, Iterable, Customer.io, CleverTap, Airship, MoEngage, Insider — confirmed via 2026-05 industry survey).
- License: MIT. Source is fully open; the hosted BzEngage platform is the paid component.
Package contents #
- Extract
BzEngageSDKfrom the test app (bzengage-test-ios-app/lib/) into a standalone Flutter package. Test app now depends on this package. - iOS
BzEngageNotificationServicebase class underios/Classes/— customer apps subclass it inside their NSE target to enable rich push (image attach) with zero boilerplate. BzEngageFlutterSdkPluginstubs (iOS Swift + Android Kotlin) so Flutter recognises the package as a plugin and resolves its Cocoapod at build time.- Public API surfaces via
package:bzengage_flutter_sdk/bzengage_flutter_sdk.dart.
Path forward #
- Phase 1 (now): SDK lives inside the
bzengagemonorepo for development convenience; test app usespath:dependency. - Phase 2 (pre-customer): split into standalone GitHub repo + publish to pub.dev as a verified publisher.
- Phase 3 (production): add iOS native (Swift) + Android native (Kotlin) + React Native SDKs to cover non-Flutter customers.