Repository navigation
feat: [SDK-5581] latch APP_STARTUP flags from a small startup file - #2808
Conversation
FeatureManager now keeps the APP_STARTUP subset of the remote flags in its own prefs file (OneSignalStartupFlags) and reads it once per process. - Code that runs before SDK init can read startup flags through FeatureManager.startupFlags without loading the OneSignal prefs file on main. - FeatureManager latches APP_STARTUP flags from the same snapshot, so isEnabled and telemetry tags match what pre-init code latched. IMMEDIATE flags still come from ConfigModel. - The file is written from start() (off main) and on later flag changes. - Missing, unreadable, or no context means off. Co-authored-by: Cursor <cursoragent@cursor.com>
📊 Diff Coverage ReportDiff Coverage Report (Changed Lines Only)Gate: aggregate coverage on changed executable lines must be ≥ 80% (JaCoCo line data for lines touched in the diff). Changed Files Coverage
Overall (aggregate gate)66/70 touched executable lines covered (94.3% — requires ≥ 80%) |
- Bump OneSignal-KMP-SDK for StartupFlagsLatch and FeatureManager.refreshAtStartup. Android now only supplies the prefs store, so iOS gets the same snapshot and latching. - Write the startup file with commit() so a remote turn-off is on disk before the process can die. A failed commit is logged, not dropped. - If the cached config cannot be applied at init, still latch APP_STARTUP flags from the snapshot, so a later refresh cannot latch them from the store. - Test that turning a flag off remotely reaches the next process. Co-authored-by: Cursor <cursoragent@cursor.com>
- On API 24+ OneSignalStartupFlags lives in device-protected storage, so receivers and jobs that start before first unlock read real values instead of caching an unreadable, all off snapshot for the process. - The file has not shipped, so there is nothing to migrate. - Document that the snapshot is one value per process, even when unreadable, and that the file must never hold secrets. Co-authored-by: Cursor <cursoragent@cursor.com>
Credential-encrypted prefs throw before first unlock; the device-protected copy still answers, so the snapshot is STORE with the real values. Co-authored-by: Cursor <cursoragent@cursor.com>
…ags under the lock start() and a flags fetch could mirror concurrently and leave an older flag list on disk. Writes now take a lock and read ConfigModel inside it. README notes that local overrides only reach isEnabled. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
|
Lets resolve kmp issues first |
There was a problem hiding this comment.
Multi-model review (Opus 5.5, GPT 5.6 Sol, Grok 4.7) of the APP_STARTUP latch.
The design matches the goal: a small device-protected file, one snapshot per process, IMMEDIATE flags still from ConfigModel, writes only after start(). A few issues are worth addressing before this is the source of truth for pre-init readers.
Act on
- The first snapshot is sticky, including
NO_STORE/UNREADABLE.FeatureManagerinit and any pre-initstartupFlagscall cache the first result for the process.initEssentialscan start cached remote logging and emit logs beforeApplicationService.start(), so a background telemetry read ofenabledFeatureFlagscan constructFeatureManagerwhileappContextstill throws (mainly whenActivityLifecycleInitializerdid not already set it). That latches every APP_STARTUP flag off. Same outcome if a pre-init caller passes{ null }or a context whoseapplicationContextis null (attachBaseContext/ some direct-boot paths):storageContext()doesapplicationContextthencreateDeviceProtectedStorageContext(), and a throw is latched asUNREADABLEeven when the file is readable. - A failed read/write can leave the file unrepaired. If
mirrorskipswritewhenread()throws, or whencommit()fails after already updating the in-memory prefs map,mirrorStartupFlagsonly logs. Later processes keep seeing off/stale flags.
Consider
FeatureManager.startupFlagsisinternaltocore. README readers in other modules (FCM, sync jobs) cannot call it without a public/friend entry point.SharedPreferencesis not multi-process-safe; a secondary process can skip or clobber the file.onModelReplacedmirrors on every tag and doescommit()I/O under the store replace lock.- The startup file uses
commit()whileConfigModelis async; a crash between the two can oscillate flags across launches.
Noted
- KMP pin to
8f5e5d8is already called out (re-pin after KMP lands). - Not scoping the file by app ID is fine for the usual single-appId process.
- First-launch-after-upgrade “changed remotely / NEXT_RUN” logs are noisy, not wrong.
Sent by Cursor Automation: PR Reviews
- Pin KMP to the review fixes. - Mirror under the latch's lock instead of a local one. - IMMEDIATE flags read false instead of throwing. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
|
Rebase to latest main |
|
Some findings:
|
Co-authored-by: Cursor <cursoragent@cursor.com>
- Fall back to the process Application when the caller has no context yet, and warn when there is none. - Retry the write after a failed commit, since SharedPreferences keeps the uncommitted value in memory. - Log the snapshot source at debug when FeatureManager is built. - Drop the test-only write(context, List) overload. Co-authored-by: Cursor <cursoragent@cursor.com>
…ng on ActivityThread - AppContext holds the application Context noted by the startup initializer and at the start of init. - The startup flags snapshot falls back to it, so no hidden API is called. - With no noted context the snapshot stays NO_STORE and warns. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Thanks, all five addressed.
|
…allers Co-authored-by: Cursor <cursoragent@cursor.com>
nan-li
left a comment
There was a problem hiding this comment.
Approving after other open questions are closed
Co-authored-by: Cursor <cursoragent@cursor.com>
…t is null Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>


Description
One Line Summary
FeatureManagerkeeps APP_STARTUP flags in a small prefs file and latches them once per process, so code that runs before SDK init can read them cheaply and always agrees withisEnabled.Details
Motivation
Some APP_STARTUP flags have to be decided before the OneSignal store loads. The first one is
sdk_dispatcher_bootstrap(#2805): the SDK picks its thread pools on the first dispatcher call, often on main, beforeinitWithContextreadsConfigModelon IO. ReadingConfigModelthat early loads the whole OneSignal prefs file (operations queue included) on main, which 5.10.x never did.A separate copy alone is not enough. 5.9.9 through 5.10.2 already persist every remote key in
ConfigModel. IfFeatureManagerkeeps latching APP_STARTUP flags from the store while pre-init code reads the copy, the first launch after an upgrade can run with the flag off whileisEnabledand every telemetry event say it is on. That would corrupt the on/off cohort comparison used to judge a rollout.Depends on KMP #29, which holds the shared latch (
StartupFlagsLatch,StartupFlags,FeatureManager.refreshAtStartup) so iOS gets the same behavior. KMP #29 is merged and the submodule is pinned to its main merge commit (35d538a).Scope
OneSignalStartupFlagsprefs file holding the enabled APP_STARTUP keys. Android implements only the store (PrefsStartupFlagsStore) and the process-wide holder (StartupFlagsCache).FeatureManager:ConfigModel;start()(now anIStartableService, off main) and on later remote flag changes, never beforestart();FeatureManager.startupFlags { context }for code that runs before init.commit(), so a remote turn-off is on disk before the process can die and the next launch picks it up. A failed commit throws and is logged.ConfigModelinside its lock, sostart()racing a flags fetch cannot leave an older flag list on disk. An unreadable file is rewritten rather than skipped, and so is one whose lastcommit()failed, sinceSharedPreferenceskeeps the uncommitted value in memory.com.onesignal.common.AppContext(the startup initializer, andinitEssentialsbefore the logger), so a snapshot taken beforeApplicationServicestarts still reads the file. No reflection: if nothing noted a context, the snapshot is off for the process and warns in logcat. The source is logged at debug whenFeatureManageris built.StartupFlags.isEnabledreturns false for IMMEDIATE flags.README.mdin the features package documents how to add a startup flag and the rules.Not changed: IMMEDIATE flag behavior, the flags fetch,
ConfigModelpersistence.Known cost: on the first launch of the version that ships this, the file does not exist yet, so APP_STARTUP flags (today
sdk_custom_logging) are off for that one launch even if the store has them. The next launch picks them up.Testing
Unit testing
StartupFlagsCacheTests: file follows remote flags (any case), only APP_STARTUP catalog keys are written, IMMEDIATE flags read false, reads ignore the config store, unreadable file and no context are off, the snapshot is read once per process,FeatureManagerreports the snapshot when it disagrees with the store, and the file is written onstart()and later updates but never beforestart().FeatureManagerbuilt before the app context is set still reads the file.FeatureManagerTests: APP_STARTUP flags latch from the snapshot, not the store; IMMEDIATE flags still follow the store.OneSignalTestProjectConfigresets the snapshot around every spec.start(), turn-off, locked boot, racing writes, unreadable rewrite, failed-commit retry, and the noted-context path.Manual testing
Not yet; covered by unit tests. The first consumer (#2805) will be device tested.
Affected code checklist
Checklist
Overview
Testing
Final pass