Skip to content

feat: [SDK-5581] latch APP_STARTUP flags from a small startup file - #2808

Merged
abdulraqeeb33 merged 16 commits into
mainfrom
ar/startup-flags
Oct 22, 2026
Merged

abdulraqeeb33 merged 16 commits into
mainfrom
ar/startup-flags

Conversation

@abdulraqeeb33

@abdulraqeeb33 abdulraqeeb33 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Description

One Line Summary

FeatureManager keeps 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 with isEnabled.

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, before initWithContext reads ConfigModel on IO. Reading ConfigModel that 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. If FeatureManager keeps 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 while isEnabled and 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

  • New OneSignalStartupFlags prefs file holding the enabled APP_STARTUP keys. Android implements only the store (PrefsStartupFlagsStore) and the process-wide holder (StartupFlagsCache).
  • The file lives in device-protected storage on API 24+, so receivers and jobs that run before first unlock read real values instead of "unreadable". It must never hold anything secret.
  • FeatureManager:
    • latches APP_STARTUP flags from a once-per-process snapshot of that file; IMMEDIATE flags still come from ConfigModel;
    • writes the file from start() (now an IStartableService, off main) and on later remote flag changes, never before start();
    • exposes FeatureManager.startupFlags { context } for code that runs before init.
  • Writes use 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.
  • Writes are serialized by the KMP latch and read ConfigModel inside its lock, so start() 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 last commit() failed, since SharedPreferences keeps the uncommitted value in memory.
  • One value per process, even when unreadable: the first read wins, so code that latched "off" never disagrees with code that runs later.
  • Missing file, unreadable file, or no context means every startup flag is off. SDK entry points note the application context in com.onesignal.common.AppContext (the startup initializer, and initEssentials before the logger), so a snapshot taken before ApplicationService starts 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 when FeatureManager is built.
  • IMMEDIATE and unknown keys are never written; StartupFlags.isEnabled returns false for IMMEDIATE flags.
  • README.md in the features package documents how to add a startup flag and the rules.

Not changed: IMMEDIATE flag behavior, the flags fetch, ConfigModel persistence.

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, FeatureManager reports the snapshot when it disagrees with the store, and the file is written on start() and later updates but never before start().
  • Also: a remote turn-off reaches the next process, a failed commit throws, the file is in device-protected storage, a locked boot still reads the flags, racing writes leave the newest flags on disk, an unreadable file is rewritten, a write after a failed commit retries, and FeatureManager built 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.
  • OneSignalTestProjectConfig resets the snapshot around every spec.
  • Verified red with the fix reverted: snapshot, before-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

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

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>
@abdulraqeeb33
abdulraqeeb33 requested a review from a team October 21, 2026 15:42
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

📊 Diff Coverage Report

Diff 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

  • AppContext.kt: 9/10 touched executable lines (90.0%) (30 touched lines in diff)
  • ActivityLifecycleInitializer.kt: 1/1 touched executable lines (100.0%) (2 touched lines in diff)
  • FeatureManager.kt: 25/27 touched executable lines (92.6%) (62 touched lines in diff)
  • StartupFlagsCache.kt: 30/31 touched executable lines (96.8%) (101 touched lines in diff)
  • OneSignalImp.kt: 1/1 touched executable lines (100.0%) (3 touched lines in diff)

Overall (aggregate gate)

66/70 touched executable lines covered (94.3% — requires ≥ 80%)

📥 View workflow run

- 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>
AR Abdul Azeez and others added 4 commits October 21, 2026 12:13
- 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>
@abdulraqeeb33
abdulraqeeb33 marked this pull request as ready for review October 21, 2026 19:57
@fadi-george
fadi-george added this pull request to stack #2810 October 21, 2026 20:20
@fadi-george

Copy link
Copy Markdown
Contributor

Lets resolve kmp issues first

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. The first snapshot is sticky, including NO_STORE / UNREADABLE. FeatureManager init and any pre-init startupFlags call cache the first result for the process. initEssentials can start cached remote logging and emit logs before ApplicationService.start(), so a background telemetry read of enabledFeatureFlags can construct FeatureManager while appContext still throws (mainly when ActivityLifecycleInitializer did not already set it). That latches every APP_STARTUP flag off. Same outcome if a pre-init caller passes { null } or a context whose applicationContext is null (attachBaseContext / some direct-boot paths): storageContext() does applicationContext then createDeviceProtectedStorageContext(), and a throw is latched as UNREADABLE even when the file is readable.
  2. A failed read/write can leave the file unrepaired. If mirror skips write when read() throws, or when commit() fails after already updating the in-memory prefs map, mirrorStartupFlags only logs. Later processes keep seeing off/stale flags.

Consider

  • FeatureManager.startupFlags is internal to core. README readers in other modules (FCM, sync jobs) cannot call it without a public/friend entry point.
  • SharedPreferences is not multi-process-safe; a secondary process can skip or clobber the file.
  • onModelReplaced mirrors on every tag and does commit() I/O under the store replace lock.
  • The startup file uses commit() while ConfigModel is async; a crash between the two can oscillate flags across launches.

Noted

  • KMP pin to 8f5e5d8 is 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.
Open in Web View Automation 

Sent by Cursor Automation: PR Reviews

AR Abdul Azeez and others added 2 commits October 21, 2026 15:29
- 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>
@fadi-george

Copy link
Copy Markdown
Contributor

Rebase to latest main

@fadi-george

Copy link
Copy Markdown
Contributor

Some findings:

  • Non-blocking: FeatureManager.kt:67, when the androidx startup initializer didn't run (provider removed, or non-default process), the remote WARN from logStartupDiagnostics can resolve FeatureManager before ensureApplicationServiceStarted. NO_STORE gets latched, so startup flags stay off for that process, and start() can write an empty list over a good file. Not latching when the context is missing, or starting ApplicationService before the logger, would fix it.
  • Non-blocking: StartupFlags.kt:71 / StartupFlagsCache.kt:73, a failed commit() still updates SharedPreferences in memory, so later mirrors see equal keys and skip the write. A remote turn-off can be lost for one launch. Maybe force a write on the next mirror after a failed commit.
  • Nit: StartupFlags.kt:23, require() on an IMMEDIATE flag throws during host app startup. Returning false and logging seems safer.
  • Nit: README says the snapshot source is kept for logging, but startup.source is never logged. A debug line would help explain flags being off on the first launch or after an unreadable file.
  • Nit: StartupFlagsCache.kt write(context, List) is test-only and skips the read-under-lock. Could be @VisibleForTesting internal or removed.

AR Abdul Azeez and others added 4 commits October 22, 2026 08:44
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>
@abdulraqeeb33

Copy link
Copy Markdown
Contributor Author

Thanks, all five addressed.

  1. Entry points now note the application context (AppContext), including initEssentials before the logger, so the early FeatureManager reads the file. With nothing noted it's off and warns in logcat, no reflection (52dd35b). start() mirrors ConfigModel, not the snapshot, so it never writes an empty list over a good file.
  2. After a failed commit the next mirror rewrites, even with the same flags (13522d3).
  3. isEnabled returns false for IMMEDIATE flags, from the KMP merge (pinned in c4e0a57).
  4. Snapshot source logged at debug when FeatureManager is built (13522d3).
  5. write(context, List) removed; tests use the supplier (13522d3).

…allers

Co-authored-by: Cursor <cursoragent@cursor.com>

@nan-li nan-li left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving after other open questions are closed

Co-authored-by: Cursor <cursoragent@cursor.com>
AR Abdul Azeez and others added 2 commits October 22, 2026 13:12
…t is null

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33
abdulraqeeb33 merged commit cda93b1 into main Oct 22, 2026
7 of 9 checks passed
@abdulraqeeb33
abdulraqeeb33 deleted the ar/startup-flags branch October 22, 2026 18:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants