From f6aed5d6bfb43c0ba2579f4e686bdba574143b05 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 21 Oct 2026 10:36:58 -0500 Subject: [PATCH 01/15] feat: [SDK-5581] latch APP_STARTUP flags from a small startup file 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 --- .../java/com/onesignal/core/CoreModule.kt | 4 +- .../core/internal/features/FeatureManager.kt | 57 ++++++- .../core/internal/features/README.md | 50 ++++++ .../internal/features/StartupFlagsCache.kt | 80 ++++++++++ .../onesignal/OneSignalTestProjectConfig.kt | 3 + .../internal/features/FeatureManagerTests.kt | 55 +++++-- .../features/StartupFlagsCacheTests.kt | 148 ++++++++++++++++++ 7 files changed, 384 insertions(+), 13 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt index 614903b8f2..e20d9b44ee 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt @@ -79,7 +79,9 @@ internal class CoreModule : IModule { // Params (Config) builder.register().provides() - builder.register().provides() + builder.register() + .provides() + .provides() builder.register().provides() builder.register().provides() builder.register().provides() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt index 2a261acf88..9241c5edb1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt @@ -1,11 +1,15 @@ package com.onesignal.core.internal.features +import android.content.Context import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.common.modeling.ModelChangedArgs +import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.startup.IStartableService import com.onesignal.debug.internal.logging.Logging +import com.onesignal.features.FeatureActivationMode import com.onesignal.features.FeatureFlag import com.onesignal.features.FeatureFlagMetadata import com.onesignal.features.FeatureManager as SharedFeatureManager @@ -50,9 +54,14 @@ interface IFeatureManager { @Suppress("TooGenericExceptionCaught") internal class FeatureManager( private val configModelStore: ConfigModelStore, -) : IFeatureManager, ISingletonModelStoreChangeHandler { + private val applicationService: IApplicationService, +) : IFeatureManager, IStartableService, ISingletonModelStoreChangeHandler { private val latch = SharedFeatureManager() + // Mirror writes wait for start(), which runs off main, so construction on main never writes. + @Volatile + private var started = false + init { Logging.debug("OneSignal: FeatureManager initializing from cached config features") try { @@ -63,6 +72,11 @@ internal class FeatureManager( configModelStore.subscribe(this) } + override fun start() { + started = true + mirrorStartupFlags(configModelStore.model) + } + override fun isEnabled(feature: FeatureFlag): Boolean = latch.isEnabled(feature) override fun enabledFeatureKeys(): List = latch.enabledFeatureKeys() @@ -81,6 +95,7 @@ internal class FeatureManager( tag: String, ) { Logging.debug("OneSignal: FeatureManager.onModelReplaced(tag=$tag)") + if (started) mirrorStartupFlags(model) if (tag == ModelChangeTags.HYDRATE || tag == ModelChangeTags.NORMAL) { try { refreshFrom(model, applyAppStartupFlags = false) @@ -99,6 +114,9 @@ internal class FeatureManager( args.property == ConfigModel::sdkRemoteFeatureFlagMetadata.name ) { Logging.debug("OneSignal: FeatureManager.onModelUpdated(property=${args.property}, tag=$tag)") + if (started && args.property == ConfigModel::sdkRemoteFeatureFlags.name) { + mirrorStartupFlags(configModelStore.model) + } try { refreshFrom(configModelStore.model, applyAppStartupFlags = false) } catch (t: Throwable) { @@ -116,8 +134,8 @@ internal class FeatureManager( "OneSignal: Local feature override enabled for testing only: $localFeatureOverrides", ) } - val deferred = - latch.refresh(model.sdkRemoteFeatureFlags, applyAppStartupFlags, localFeatureOverrides) + val keys = if (applyAppStartupFlags) startupLatchKeys(model) else model.sdkRemoteFeatureFlags + val deferred = latch.refresh(keys, applyAppStartupFlags, localFeatureOverrides) for (change in deferred) { Logging.info( "OneSignal: Feature ${change.key} changed remotely to ${change.desiredEnabled} " + @@ -126,7 +144,40 @@ internal class FeatureManager( } } + // APP_STARTUP values come from the process snapshot pre-init code also latched from, + // so telemetry never reports a startup flag the running process is not using. + private fun startupLatchKeys(model: ConfigModel): List { + val startup = StartupFlagsCache.snapshot { appContextOrNull() } + val immediate = model.sdkRemoteFeatureFlags.filterNot { it.lowercase() in APP_STARTUP_KEYS } + return immediate + startup.keys + } + + private fun mirrorStartupFlags(model: ConfigModel) { + try { + StartupFlagsCache.write(applicationService.appContext, model.sdkRemoteFeatureFlags) + } catch (t: Throwable) { + Logging.warn("OneSignal: failed to mirror startup flags: ${t.message}", t) + } + } + + @Suppress("SwallowedException") + private fun appContextOrNull(): Context? = + try { + applicationService.appContext + } catch (t: Throwable) { + null + } + companion object { + /** APP_STARTUP flags for code that runs before SDK init. Same values [isEnabled] reports. */ + fun startupFlags(context: () -> Context?): StartupFlags = StartupFlagsCache.snapshot(context) + + /** Forgets this process's startup flags, as a new process would. */ + internal fun resetStartupFlagsForTest() = StartupFlagsCache.setSnapshotForTest(null) + + private val APP_STARTUP_KEYS = + FeatureFlag.entries.filter { it.activationMode == FeatureActivationMode.APP_STARTUP }.map { it.key }.toSet() + /** * Local-only test hook for forcing features ON without backend config. * Add feature keys here while testing locally, e.g. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md new file mode 100644 index 0000000000..8045c7fc35 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md @@ -0,0 +1,50 @@ +# Startup flags + +`FeatureManager` owns every feature flag. For `APP_STARTUP` flags it also keeps a tiny prefs file, +`OneSignalStartupFlags`, so code that runs before SDK init can read them without loading the whole +OneSignal prefs file (operations queue included) on the main thread. Typical pre-init readers make a +choice that lives for the whole process, such as which thread pools or logging pipeline to build. + +## Adding a startup flag + +1. Add it to `FeatureFlag` in the KMP catalog with `FeatureActivationMode.APP_STARTUP`. +2. Read it: + - After init (anything with an injected `IFeatureManager`): `featureManager.isEnabled(flag)`. + - Before init (no service container yet): `FeatureManager.startupFlags { context }.isEnabled(flag)`. + +Nothing else. Both calls return the same value for the life of the process. + +## How it works + +1. Process starts. The first reader, before or after init, triggers a single read of the file. That + snapshot is the process value for every APP_STARTUP flag. +2. `FeatureManager` is created and latches APP_STARTUP flags from the snapshot, IMMEDIATE flags from + `ConfigModel`. +3. `FeatureManager.start()` (an `IStartableService`, so off main) copies the APP_STARTUP subset of + `ConfigModel.sdkRemoteFeatureFlags` into the file. +4. A flags fetch updates `ConfigModel`. IMMEDIATE flags apply now. APP_STARTUP changes are logged as + deferred, and the file is rewritten for the next process. + +## Rules + +- **Only APP_STARTUP flags.** IMMEDIATE flags change mid-run and the file lags the store, so they are + never written and `StartupFlags.isEnabled` rejects them. +- **The snapshot is the process value.** Telemetry tags and `isEnabled` must match what early code + latched, so APP_STARTUP flags never come from the store at runtime. +- **Missing or unreadable means off.** A missing file, a read that throws, or no application context + all give an empty snapshot. The source (`FILE`, `UNREADABLE`, `NO_APPLICATION`) is kept for logging. +- **One launch behind after an upgrade.** The file does not exist on the first launch of the version + that introduces it, so APP_STARTUP flags are off for that launch even if the store already has them. +- **No writes before `start()`.** `FeatureManager` can be constructed on main; the file is only written + after start. +- **Main thread cost.** The first reader opens one small XML file. Cheap, but StrictMode can still report + it as a disk read when that reader is on main. + +## Tests + +- `FeatureManager.resetStartupFlagsForTest()` forgets the snapshot, as a new process would. + `OneSignalTestProjectConfig` calls it around every spec. +- `StartupFlagsCache.setSnapshotForTest(...)` installs a fixed snapshot; `readFile` reads the file + without touching the snapshot. +- `FeatureManager.localFeatureOverrides` does not reach the snapshot. Pre-init readers need their own + test hooks. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt new file mode 100644 index 0000000000..b451fddcc5 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt @@ -0,0 +1,80 @@ +package com.onesignal.core.internal.features + +import android.content.Context +import android.util.Log +import com.onesignal.features.FeatureActivationMode +import com.onesignal.features.FeatureFlag + +/** APP_STARTUP flags as one process sees them. */ +internal class StartupFlags( + val source: Source, + private val enabledKeys: Set, +) { + enum class Source { FILE, UNREADABLE, NO_APPLICATION } + + val keys: Set get() = enabledKeys + + fun isEnabled(flag: FeatureFlag): Boolean { + require(flag.activationMode == FeatureActivationMode.APP_STARTUP) { "${flag.key} is not APP_STARTUP" } + return flag.key in enabledKeys + } +} + +/** + * Small prefs file holding the cached APP_STARTUP flags, read once per process so pre-store + * code (often on main) and [FeatureManager] latch the same values. See README.md in this package. + */ +@Suppress("TooGenericExceptionCaught") +internal object StartupFlagsCache { + @Volatile + private var snapshot: StartupFlags? = null + private val lock = Any() + + /** The first caller in the process reads the file; everyone after gets the same answer. */ + fun snapshot(context: () -> Context?): StartupFlags { + snapshot?.let { return it } + synchronized(lock) { + snapshot?.let { return it } + return readFile(context()).also { snapshot = it } + } + } + + internal fun readFile(context: Context?): StartupFlags { + if (context == null) return StartupFlags(StartupFlags.Source.NO_APPLICATION, emptySet()) + return try { + StartupFlags(StartupFlags.Source.FILE, prefs(context).getStringSet(KEY_ENABLED, null).orEmpty().toSet()) + } catch (t: Throwable) { + // android.util.Log, not Logging: Logging can dispatch, and this can run before SDK init. + Log.w(TAG, "startup flags unreadable, treating all as off", t) + StartupFlags(StartupFlags.Source.UNREADABLE, emptySet()) + } + } + + /** Mirrors the APP_STARTUP subset of [remoteFlags] for the next process. Call off the main thread. */ + fun write( + context: Context, + remoteFlags: List, + ) { + val enabled = + FeatureFlag.entries + .filter { flag -> + flag.activationMode == FeatureActivationMode.APP_STARTUP && + remoteFlags.any { it.equals(flag.key, ignoreCase = true) } + }.map { it.key } + .toSet() + val prefs = prefs(context) + if (prefs.getStringSet(KEY_ENABLED, emptySet()) == enabled) return + prefs.edit().putStringSet(KEY_ENABLED, enabled).apply() + } + + /** Null forgets the snapshot, as a new process would. */ + internal fun setSnapshotForTest(flags: StartupFlags?) { + synchronized(lock) { snapshot = flags } + } + + private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(STORE, Context.MODE_PRIVATE) + + private const val STORE = "OneSignalStartupFlags" + private const val KEY_ENABLED = "enabled" + private const val TAG = "OneSignal" +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalTestProjectConfig.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalTestProjectConfig.kt index 4406110166..b4bc385edd 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalTestProjectConfig.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalTestProjectConfig.kt @@ -1,6 +1,7 @@ package com.onesignal import com.onesignal.common.threading.OneSignalDispatchers +import com.onesignal.core.internal.features.FeatureManager import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import io.kotest.core.listeners.AfterSpecListener @@ -31,9 +32,11 @@ class OneSignalTestProjectConfig : AbstractProjectConfig() { private object OneSignalDispatchersResetListener : BeforeSpecListener, AfterSpecListener { override suspend fun beforeSpec(spec: Spec) { OneSignalDispatchers.resetForTest() + FeatureManager.resetStartupFlagsForTest() } override suspend fun afterSpec(spec: Spec) { OneSignalDispatchers.resetForTest() + FeatureManager.resetStartupFlagsForTest() } } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt index 0ea10f8758..aec702036d 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt @@ -17,6 +17,13 @@ import io.mockk.mockk import io.mockk.runs class FeatureManagerTests : FunSpec({ + fun startupSnapshot(vararg flags: FeatureFlag) { + StartupFlagsCache.setSnapshotForTest(StartupFlags(StartupFlags.Source.FILE, flags.map { it.key }.toSet())) + } + + beforeTest { startupSnapshot() } + afterTest { StartupFlagsCache.setSnapshotForTest(null) } + fun stubConfigModel(model: ConfigModel) { every { model.sdkRemoteFeatureFlags } returns emptyList() every { model.sdkRemoteFeatureFlagMetadata } returns null @@ -30,7 +37,7 @@ class FeatureManagerTests : FunSpec({ every { configModelStore.model } returns initialModel every { configModelStore.subscribe(any()) } just runs - val manager = FeatureManager(configModelStore) + val manager = FeatureManager(configModelStore, mockk(relaxed = true)) manager.isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) shouldBe false } @@ -43,7 +50,7 @@ class FeatureManagerTests : FunSpec({ every { configModelStore.model } returns initialModel every { configModelStore.subscribe(any()) } just runs - val manager = FeatureManager(configModelStore) + val manager = FeatureManager(configModelStore, mockk(relaxed = true)) manager.isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) shouldBe true } @@ -56,7 +63,7 @@ class FeatureManagerTests : FunSpec({ every { configModelStore.model } returns initialModel every { configModelStore.subscribe(any()) } just runs - val manager = FeatureManager(configModelStore) + val manager = FeatureManager(configModelStore, mockk(relaxed = true)) manager.isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) shouldBe true } @@ -69,7 +76,7 @@ class FeatureManagerTests : FunSpec({ every { configModelStore.model } returns initialModel every { configModelStore.subscribe(any()) } just runs - val manager = FeatureManager(configModelStore) + val manager = FeatureManager(configModelStore, mockk(relaxed = true)) val meta = requireNotNull(manager.remoteFeatureFlagMetadata()) meta.getValue("X").contains("\"note\"") shouldBe true @@ -84,7 +91,7 @@ class FeatureManagerTests : FunSpec({ every { configModelStore.model } returns initialModel every { configModelStore.subscribe(any()) } just runs - FeatureManager(configModelStore).remoteFeatureFlagMetadata() shouldBe null + FeatureManager(configModelStore, mockk(relaxed = true)).remoteFeatureFlagMetadata() shouldBe null } test("enabledFeatureKeys is empty when no flags are enabled") { @@ -94,7 +101,7 @@ class FeatureManagerTests : FunSpec({ every { configModelStore.model } returns initialModel every { configModelStore.subscribe(any()) } just runs - val manager = FeatureManager(configModelStore) + val manager = FeatureManager(configModelStore, mockk(relaxed = true)) manager.enabledFeatureKeys() shouldBe emptyList() } @@ -107,7 +114,7 @@ class FeatureManagerTests : FunSpec({ every { configModelStore.model } returns initialModel every { configModelStore.subscribe(any()) } just runs - val manager = FeatureManager(configModelStore) + val manager = FeatureManager(configModelStore, mockk(relaxed = true)) manager.enabledFeatureKeys() shouldBe listOf(FeatureFlag.SDK_IDENTITY_VERIFICATION.key) } @@ -118,7 +125,7 @@ class FeatureManagerTests : FunSpec({ val configModelStore = mockk() every { configModelStore.model } returns initialModel every { configModelStore.subscribe(any()) } just runs - val manager = FeatureManager(configModelStore) + val manager = FeatureManager(configModelStore, mockk(relaxed = true)) manager.isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) shouldBe false @@ -138,6 +145,7 @@ class FeatureManagerTests : FunSpec({ val initialModel = mockk() stubConfigModel(initialModel) every { initialModel.sdkRemoteFeatureFlags } returns listOf(FeatureFlag.SDK_CUSTOM_LOGGING.key) + startupSnapshot(FeatureFlag.SDK_CUSTOM_LOGGING) val configModelStore = mockk() every { configModelStore.model } returns initialModel every { configModelStore.subscribe(any()) } just runs @@ -148,7 +156,7 @@ class FeatureManagerTests : FunSpec({ val listener = ILogListener { event -> capturedLogs.add(event) } Logging.addListener(listener) try { - val manager = FeatureManager(configModelStore) + val manager = FeatureManager(configModelStore, mockk(relaxed = true)) manager.isEnabled(FeatureFlag.SDK_CUSTOM_LOGGING) shouldBe true val updatedModel = mockk() @@ -170,4 +178,33 @@ class FeatureManagerTests : FunSpec({ Logging.logLevel = originalLogLevel } } + + test("APP_STARTUP flags latch from the startup snapshot, not the store") { + val model = mockk() + stubConfigModel(model) + every { model.sdkRemoteFeatureFlags } returns + listOf(FeatureFlag.SDK_CUSTOM_LOGGING.key, FeatureFlag.SDK_IDENTITY_VERIFICATION.key) + val configModelStore = mockk() + every { configModelStore.model } returns model + every { configModelStore.subscribe(any()) } just runs + + val manager = FeatureManager(configModelStore, mockk(relaxed = true)) + + manager.isEnabled(FeatureFlag.SDK_CUSTOM_LOGGING) shouldBe false + manager.isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) shouldBe true + manager.enabledFeatureKeys() shouldBe listOf(FeatureFlag.SDK_IDENTITY_VERIFICATION.key) + } + + test("an APP_STARTUP flag in the snapshot is on even before the store has it") { + startupSnapshot(FeatureFlag.SDK_CUSTOM_LOGGING) + val model = mockk() + stubConfigModel(model) + val configModelStore = mockk() + every { configModelStore.model } returns model + every { configModelStore.subscribe(any()) } just runs + + val manager = FeatureManager(configModelStore, mockk(relaxed = true)) + + manager.isEnabled(FeatureFlag.SDK_CUSTOM_LOGGING) shouldBe true + } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt new file mode 100644 index 0000000000..543db2c6a6 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt @@ -0,0 +1,148 @@ +package com.onesignal.core.internal.features + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.common.modeling.ModelChangeTags +import com.onesignal.common.modeling.ModelChangedArgs +import com.onesignal.core.internal.config.CONFIG_NAME_SPACE +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.features.FeatureFlag +import com.onesignal.mocks.MockHelper +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.json.JSONArray + +@RobolectricTest +class StartupFlagsCacheTests : FunSpec({ + val logging = FeatureFlag.SDK_CUSTOM_LOGGING + val key = logging.key + + afterTest { + clearStores() + FeatureManager.resetStartupFlagsForTest() + } + + test("a missing file is off") { + StartupFlagsCache.readFile(appContext()).isEnabled(logging) shouldBe false + } + + test("the file follows the remote flags, regardless of case") { + val context = appContext() + StartupFlagsCache.write(context, listOf("SDK_Custom_Logging")) + StartupFlagsCache.readFile(context).isEnabled(logging) shouldBe true + StartupFlagsCache.write(context, listOf(FeatureFlag.SDK_IDENTITY_VERIFICATION.key)) + StartupFlagsCache.readFile(context).isEnabled(logging) shouldBe false + } + + test("only APP_STARTUP catalog keys reach the file") { + val context = appContext() + StartupFlagsCache.write(context, listOf(key, FeatureFlag.SDK_IDENTITY_VERIFICATION.key, "not_in_catalog")) + context + .getSharedPreferences("OneSignalStartupFlags", Context.MODE_PRIVATE) + .getStringSet("enabled", null) shouldBe setOf(key) + } + + test("reading an IMMEDIATE flag is rejected") { + shouldThrow { + StartupFlagsCache.readFile(appContext()).isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) + } + } + + test("read ignores the OneSignal config store") { + val context = appContext() + writeConfigStore(context, listOf(key)) + StartupFlagsCache.readFile(context).isEnabled(logging) shouldBe false + } + + test("an unreadable file is off") { + val broken = mockk() + every { broken.applicationContext } returns broken + every { broken.getSharedPreferences(any(), any()) } throws SecurityException("prefs unavailable") + + val flags = StartupFlagsCache.readFile(broken) + + flags.source shouldBe StartupFlags.Source.UNREADABLE + flags.isEnabled(logging) shouldBe false + } + + test("no application context is off") { + StartupFlagsCache.readFile(null).source shouldBe StartupFlags.Source.NO_APPLICATION + } + + test("the snapshot is read once per process") { + val context = appContext() + StartupFlagsCache.write(context, listOf(key)) + FeatureManager.resetStartupFlagsForTest() + FeatureManager.startupFlags { context }.isEnabled(logging) shouldBe true + + StartupFlagsCache.write(context, emptyList()) + FeatureManager.startupFlags { context }.isEnabled(logging) shouldBe true + + FeatureManager.resetStartupFlagsForTest() + FeatureManager.startupFlags { context }.isEnabled(logging) shouldBe false + } + + test("FeatureManager reports the snapshot, not the store, when they disagree") { + val context = appContext() + StartupFlagsCache.write(context, emptyList()) + FeatureManager.startupFlags { context }.isEnabled(logging) shouldBe false + + val store = MockHelper.configModelStore { it.sdkRemoteFeatureFlags = listOf(key) } + val applicationService = MockHelper.applicationService() + every { applicationService.appContext } returns context + val manager = FeatureManager(store, applicationService) + + manager.isEnabled(logging) shouldBe false + manager.enabledFeatureKeys() shouldBe emptyList() + } + + test("FeatureManager mirrors the cached flags on start and on later updates, never before start") { + val context = appContext() + val store = MockHelper.configModelStore { it.sdkRemoteFeatureFlags = listOf(key) } + val applicationService = MockHelper.applicationService() + every { applicationService.appContext } returns context + val manager = FeatureManager(store, applicationService) + val update = + ModelChangedArgs(store.model, ConfigModel::sdkRemoteFeatureFlags.name, ConfigModel::sdkRemoteFeatureFlags.name, null, null) + + manager.onModelUpdated(update, ModelChangeTags.NORMAL) + StartupFlagsCache.readFile(context).isEnabled(logging) shouldBe false + + manager.start() + StartupFlagsCache.readFile(context).isEnabled(logging) shouldBe true + + store.model.sdkRemoteFeatureFlags = emptyList() + manager.onModelUpdated(update, ModelChangeTags.NORMAL) + StartupFlagsCache.readFile(context).isEnabled(logging) shouldBe false + } +}) + +private fun appContext(): Context = ApplicationProvider.getApplicationContext() + +private fun clearStores() { + val context = appContext() + StartupFlagsCache.write(context, emptyList()) + context + .getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + .edit() + .remove(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + CONFIG_NAME_SPACE) + .commit() +} + +private fun writeConfigStore( + context: Context, + flags: List, +) { + val model = ConfigModel().apply { sdkRemoteFeatureFlags = flags } + context + .getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + .edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + CONFIG_NAME_SPACE, JSONArray().put(model.toJSON()).toString()) + .commit() +} From 41f1dcfc8c2d5fec78e71f9e046f56522cd45c35 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 21 Oct 2026 11:59:22 -0500 Subject: [PATCH 02/15] refactor: [SDK-5581] move startup flags latching into KMP - 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 --- OneSignal-KMP-SDK | 2 +- .../core/internal/features/FeatureManager.kt | 50 ++++----- .../core/internal/features/README.md | 15 ++- .../internal/features/StartupFlagsCache.kt | 103 ++++++++---------- .../internal/features/FeatureManagerTests.kt | 4 +- .../features/StartupFlagsCacheTests.kt | 47 +++++++- 6 files changed, 128 insertions(+), 93 deletions(-) diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index ee61d46732..64a8671fea 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit ee61d4673265876e5cab6328b7a0745f79b7a5aa +Subproject commit 64a8671fea7b0169adf12c4bd19262fb5af3ea88 diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt index 9241c5edb1..e32fcc4fad 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt @@ -9,9 +9,10 @@ import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.startup.IStartableService import com.onesignal.debug.internal.logging.Logging -import com.onesignal.features.FeatureActivationMode +import com.onesignal.features.DeferredFeatureActivation import com.onesignal.features.FeatureFlag import com.onesignal.features.FeatureFlagMetadata +import com.onesignal.features.StartupFlags import com.onesignal.features.FeatureManager as SharedFeatureManager /** @@ -44,10 +45,9 @@ interface IFeatureManager { /** * Android host for the shared [SharedFeatureManager] latch. * - * Persistence ([ConfigModel]) and store subscriptions stay here. Catalog, activation - * modes, and isEnabled latching live in KMP so iOS can call the same - * [SharedFeatureManager.refresh] with cached keys at process start - * (`applyAppStartupFlags = true`) and again after later fetches (`false`). + * Persistence ([ConfigModel]), the startup flags file, and store subscriptions stay here. + * Catalog, activation modes, startup snapshot, and latching live in KMP: this host calls + * [SharedFeatureManager.refreshAtStartup] once, then [SharedFeatureManager.refresh] after fetches. * * Shared [SharedFeatureManager] is thread-safe; this host does not add an extra lock. */ @@ -64,10 +64,17 @@ internal class FeatureManager( init { Logging.debug("OneSignal: FeatureManager initializing from cached config features") + if (localFeatureOverrides.isNotEmpty()) { + Logging.warn("OneSignal: Local feature override enabled for testing only: $localFeatureOverrides") + } + // APP_STARTUP flags come from the snapshot pre-init code also latched from, so telemetry + // never reports a startup flag the running process is not using. + val startup = StartupFlagsCache.snapshot { appContextOrNull() } try { - refreshFrom(configModelStore.model, applyAppStartupFlags = true) + logDeferred(latch.refreshAtStartup(configModelStore.model.sdkRemoteFeatureFlags, startup, localFeatureOverrides)) } catch (t: Throwable) { Logging.error("OneSignal: Failed to initialize feature states from cached config", t) + latch.refreshAtStartup(emptyList(), startup, localFeatureOverrides) } configModelStore.subscribe(this) } @@ -98,7 +105,7 @@ internal class FeatureManager( if (started) mirrorStartupFlags(model) if (tag == ModelChangeTags.HYDRATE || tag == ModelChangeTags.NORMAL) { try { - refreshFrom(model, applyAppStartupFlags = false) + refreshFrom(model) } catch (t: Throwable) { Logging.error("OneSignal: Failed to refresh features on model replace", t) } @@ -118,24 +125,18 @@ internal class FeatureManager( mirrorStartupFlags(configModelStore.model) } try { - refreshFrom(configModelStore.model, applyAppStartupFlags = false) + refreshFrom(configModelStore.model) } catch (t: Throwable) { Logging.error("OneSignal: Failed to refresh features on model update", t) } } } - private fun refreshFrom( - model: ConfigModel, - applyAppStartupFlags: Boolean, - ) { - if (localFeatureOverrides.isNotEmpty()) { - Logging.warn( - "OneSignal: Local feature override enabled for testing only: $localFeatureOverrides", - ) - } - val keys = if (applyAppStartupFlags) startupLatchKeys(model) else model.sdkRemoteFeatureFlags - val deferred = latch.refresh(keys, applyAppStartupFlags, localFeatureOverrides) + private fun refreshFrom(model: ConfigModel) { + logDeferred(latch.refresh(model.sdkRemoteFeatureFlags, false, localFeatureOverrides)) + } + + private fun logDeferred(deferred: List) { for (change in deferred) { Logging.info( "OneSignal: Feature ${change.key} changed remotely to ${change.desiredEnabled} " + @@ -144,14 +145,6 @@ internal class FeatureManager( } } - // APP_STARTUP values come from the process snapshot pre-init code also latched from, - // so telemetry never reports a startup flag the running process is not using. - private fun startupLatchKeys(model: ConfigModel): List { - val startup = StartupFlagsCache.snapshot { appContextOrNull() } - val immediate = model.sdkRemoteFeatureFlags.filterNot { it.lowercase() in APP_STARTUP_KEYS } - return immediate + startup.keys - } - private fun mirrorStartupFlags(model: ConfigModel) { try { StartupFlagsCache.write(applicationService.appContext, model.sdkRemoteFeatureFlags) @@ -175,9 +168,6 @@ internal class FeatureManager( /** Forgets this process's startup flags, as a new process would. */ internal fun resetStartupFlagsForTest() = StartupFlagsCache.setSnapshotForTest(null) - private val APP_STARTUP_KEYS = - FeatureFlag.entries.filter { it.activationMode == FeatureActivationMode.APP_STARTUP }.map { it.key }.toSet() - /** * Local-only test hook for forcing features ON without backend config. * Add feature keys here while testing locally, e.g. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md index 8045c7fc35..7180be53e3 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md @@ -14,6 +14,13 @@ choice that lives for the whole process, such as which thread pools or logging p Nothing else. Both calls return the same value for the life of the process. +## Where the logic lives + +The snapshot, key filtering, write skipping, and latching are in KMP (`StartupFlagsLatch`, +`StartupFlags`, `FeatureManager.refreshAtStartup`), so iOS gets the same behavior by implementing +`IStartupFlagsStore`. Android only provides that store (`PrefsStartupFlagsStore`) and the +process-wide holder (`StartupFlagsCache`). + ## How it works 1. Process starts. The first reader, before or after init, triggers a single read of the file. That @@ -22,8 +29,9 @@ Nothing else. Both calls return the same value for the life of the process. `ConfigModel`. 3. `FeatureManager.start()` (an `IStartableService`, so off main) copies the APP_STARTUP subset of `ConfigModel.sdkRemoteFeatureFlags` into the file. -4. A flags fetch updates `ConfigModel`. IMMEDIATE flags apply now. APP_STARTUP changes are logged as - deferred, and the file is rewritten for the next process. +4. A flags fetch updates `ConfigModel`. IMMEDIATE flags apply now. APP_STARTUP changes, on or off, + are logged as deferred, and the file is rewritten for the next process. The write uses `commit()`, + so it is on disk before the process can die. ## Rules @@ -32,7 +40,8 @@ Nothing else. Both calls return the same value for the life of the process. - **The snapshot is the process value.** Telemetry tags and `isEnabled` must match what early code latched, so APP_STARTUP flags never come from the store at runtime. - **Missing or unreadable means off.** A missing file, a read that throws, or no application context - all give an empty snapshot. The source (`FILE`, `UNREADABLE`, `NO_APPLICATION`) is kept for logging. + all give an empty snapshot. The source (`STORE`, `MISSING`, `UNREADABLE`, `NO_STORE`) is kept for + logging. - **One launch behind after an upgrade.** The file does not exist on the first launch of the version that introduces it, so APP_STARTUP flags are off for that launch even if the store already has them. - **No writes before `start()`.** `FeatureManager` can be constructed on main; the file is only written diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt index b451fddcc5..dc9fc0682f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt @@ -2,79 +2,72 @@ package com.onesignal.core.internal.features import android.content.Context import android.util.Log -import com.onesignal.features.FeatureActivationMode -import com.onesignal.features.FeatureFlag - -/** APP_STARTUP flags as one process sees them. */ -internal class StartupFlags( - val source: Source, - private val enabledKeys: Set, -) { - enum class Source { FILE, UNREADABLE, NO_APPLICATION } - - val keys: Set get() = enabledKeys - - fun isEnabled(flag: FeatureFlag): Boolean { - require(flag.activationMode == FeatureActivationMode.APP_STARTUP) { "${flag.key} is not APP_STARTUP" } - return flag.key in enabledKeys - } -} +import com.onesignal.features.IStartupFlagsStore +import com.onesignal.features.StartupFlags +import com.onesignal.features.StartupFlagsLatch /** - * Small prefs file holding the cached APP_STARTUP flags, read once per process so pre-store - * code (often on main) and [FeatureManager] latch the same values. See README.md in this package. + * Process-wide holder for the shared [StartupFlagsLatch], backed by a small prefs file so pre-init + * code (often on main) never loads the OneSignal store. See README.md in this package. */ -@Suppress("TooGenericExceptionCaught") internal object StartupFlagsCache { @Volatile - private var snapshot: StartupFlags? = null - private val lock = Any() + private var latch = StartupFlagsLatch() /** The first caller in the process reads the file; everyone after gets the same answer. */ - fun snapshot(context: () -> Context?): StartupFlags { - snapshot?.let { return it } - synchronized(lock) { - snapshot?.let { return it } - return readFile(context()).also { snapshot = it } - } - } - - internal fun readFile(context: Context?): StartupFlags { - if (context == null) return StartupFlags(StartupFlags.Source.NO_APPLICATION, emptySet()) - return try { - StartupFlags(StartupFlags.Source.FILE, prefs(context).getStringSet(KEY_ENABLED, null).orEmpty().toSet()) - } catch (t: Throwable) { - // android.util.Log, not Logging: Logging can dispatch, and this can run before SDK init. - Log.w(TAG, "startup flags unreadable, treating all as off", t) - StartupFlags(StartupFlags.Source.UNREADABLE, emptySet()) - } - } + fun snapshot(context: () -> Context?): StartupFlags = latch.snapshot { context()?.let(::PrefsStartupFlagsStore) } /** Mirrors the APP_STARTUP subset of [remoteFlags] for the next process. Call off the main thread. */ fun write( context: Context, remoteFlags: List, ) { - val enabled = - FeatureFlag.entries - .filter { flag -> - flag.activationMode == FeatureActivationMode.APP_STARTUP && - remoteFlags.any { it.equals(flag.key, ignoreCase = true) } - }.map { it.key } - .toSet() - val prefs = prefs(context) - if (prefs.getStringSet(KEY_ENABLED, emptySet()) == enabled) return - prefs.edit().putStringSet(KEY_ENABLED, enabled).apply() + latch.mirror(PrefsStartupFlagsStore(context), remoteFlags) } + /** Reads the file now, without touching this process's snapshot. */ + internal fun readFile(context: Context?): StartupFlags = StartupFlagsLatch().snapshot { context?.let(::PrefsStartupFlagsStore) } + /** Null forgets the snapshot, as a new process would. */ internal fun setSnapshotForTest(flags: StartupFlags?) { - synchronized(lock) { snapshot = flags } + latch = + StartupFlagsLatch().also { fresh -> + if (flags != null) fresh.snapshot { FixedStore(flags.keys) } + } } - private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(STORE, Context.MODE_PRIVATE) + private class FixedStore( + private val keys: Set, + ) : IStartupFlagsStore { + override fun read(): Set = keys + + override fun write(keys: Set) = Unit + } +} - private const val STORE = "OneSignalStartupFlags" - private const val KEY_ENABLED = "enabled" - private const val TAG = "OneSignal" +internal class PrefsStartupFlagsStore( + private val context: Context, +) : IStartupFlagsStore { + @Suppress("TooGenericExceptionCaught") + override fun read(): Set? = + try { + prefs().getStringSet(KEY_ENABLED, null)?.toSet() + } catch (t: Throwable) { + // android.util.Log, not Logging: Logging can dispatch, and this can run before SDK init. + Log.w(TAG, "startup flags unreadable, treating all as off", t) + throw t + } + + // commit, not apply: the next process reads this at startup, so it must be on disk when we return. + override fun write(keys: Set) { + check(prefs().edit().putStringSet(KEY_ENABLED, keys).commit()) { "startup flags commit failed" } + } + + private fun prefs() = context.applicationContext.getSharedPreferences(STORE, Context.MODE_PRIVATE) + + private companion object { + const val STORE = "OneSignalStartupFlags" + const val KEY_ENABLED = "enabled" + const val TAG = "OneSignal" + } } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt index aec702036d..39d6c1adf8 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt @@ -8,6 +8,8 @@ import com.onesignal.debug.LogLevel import com.onesignal.debug.OneSignalLogEvent import com.onesignal.debug.internal.logging.Logging import com.onesignal.features.FeatureFlag +import com.onesignal.features.StartupFlags +import com.onesignal.features.StartupFlagsSource import com.onesignal.user.internal.jwt.JwtRequirement import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe @@ -18,7 +20,7 @@ import io.mockk.runs class FeatureManagerTests : FunSpec({ fun startupSnapshot(vararg flags: FeatureFlag) { - StartupFlagsCache.setSnapshotForTest(StartupFlags(StartupFlags.Source.FILE, flags.map { it.key }.toSet())) + StartupFlagsCache.setSnapshotForTest(StartupFlags(StartupFlagsSource.STORE, flags.map { it.key }.toSet())) } beforeTest { startupSnapshot() } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt index 543db2c6a6..5fb88611ef 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt @@ -1,16 +1,20 @@ package com.onesignal.core.internal.features import android.content.Context +import android.content.SharedPreferences import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.common.modeling.ModelChangedArgs import com.onesignal.core.internal.config.CONFIG_NAME_SPACE import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.features.FeatureFlag +import com.onesignal.features.StartupFlagsSource import com.onesignal.mocks.MockHelper +import com.onesignal.mocks.MockPreferencesService import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe @@ -29,7 +33,44 @@ class StartupFlagsCacheTests : FunSpec({ } test("a missing file is off") { - StartupFlagsCache.readFile(appContext()).isEnabled(logging) shouldBe false + appContext().getSharedPreferences("OneSignalStartupFlags", Context.MODE_PRIVATE).edit().clear().commit() + val flags = StartupFlagsCache.readFile(appContext()) + + flags.source shouldBe StartupFlagsSource.MISSING + flags.isEnabled(logging) shouldBe false + } + + test("a failed commit is reported, not silently dropped") { + val context = mockk() + val prefs = mockk() + every { context.applicationContext } returns context + every { context.getSharedPreferences(any(), any()) } returns prefs + every { prefs.getStringSet(any(), any()) } returns null + every { prefs.edit().putStringSet(any(), any()).commit() } returns false + + shouldThrow { StartupFlagsCache.write(context, listOf(key)) } + } + + test("turning a flag off remotely reaches the next process") { + val context = appContext() + val store = ConfigModelStore(MockPreferencesService()) + store.model.sdkRemoteFeatureFlags = listOf(key) + val applicationService = MockHelper.applicationService() + every { applicationService.appContext } returns context + + val first = FeatureManager(store, applicationService) + first.start() + FeatureManager.resetStartupFlagsForTest() + val second = FeatureManager(store, applicationService) + second.start() + second.isEnabled(logging) shouldBe true + + store.model.setListProperty(ConfigModel::sdkRemoteFeatureFlags.name, emptyList(), ModelChangeTags.NORMAL) + second.isEnabled(logging) shouldBe true + + FeatureManager.resetStartupFlagsForTest() + FeatureManager.startupFlags { context }.isEnabled(logging) shouldBe false + FeatureManager(store, applicationService).isEnabled(logging) shouldBe false } test("the file follows the remote flags, regardless of case") { @@ -67,12 +108,12 @@ class StartupFlagsCacheTests : FunSpec({ val flags = StartupFlagsCache.readFile(broken) - flags.source shouldBe StartupFlags.Source.UNREADABLE + flags.source shouldBe StartupFlagsSource.UNREADABLE flags.isEnabled(logging) shouldBe false } test("no application context is off") { - StartupFlagsCache.readFile(null).source shouldBe StartupFlags.Source.NO_APPLICATION + StartupFlagsCache.readFile(null).source shouldBe StartupFlagsSource.NO_STORE } test("the snapshot is read once per process") { From 4dfb205b0fe286fad80f3b68d63cd507fb77f86f Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 21 Oct 2026 12:13:26 -0500 Subject: [PATCH 03/15] fix: [SDK-5581] keep the startup flags file in device-protected storage - 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 --- .../core/internal/features/README.md | 7 ++++++ .../internal/features/StartupFlagsCache.kt | 17 ++++++++++---- .../features/StartupFlagsCacheTests.kt | 22 +++++++++++++++---- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md index 7180be53e3..9014e8d9c6 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md @@ -42,6 +42,13 @@ process-wide holder (`StartupFlagsCache`). - **Missing or unreadable means off.** A missing file, a read that throws, or no application context all give an empty snapshot. The source (`STORE`, `MISSING`, `UNREADABLE`, `NO_STORE`) is kept for logging. +- **One value per process, even when unreadable.** The first read wins, including an `UNREADABLE` + one. Later readers in the same process (sync jobs, FCM receivers, prewarm) never re-read and get a + different answer, so code that already latched "off" never disagrees with code that runs later. +- **Device-protected storage.** On API 24+ the file lives in device-protected storage + (`createDeviceProtectedStorageContext()`), so receivers and jobs that start before first unlock + read real values instead of `UNREADABLE`. Below 24 there is no direct boot, so the normal context + is used. Never put anything secret in this file: it is readable while the device is locked. - **One launch behind after an upgrade.** The file does not exist on the first launch of the version that introduces it, so APP_STARTUP flags are off for that launch even if the store already has them. - **No writes before `start()`.** `FeatureManager` can be constructed on main; the file is only written diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt index dc9fc0682f..c249d29c29 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt @@ -1,6 +1,8 @@ package com.onesignal.core.internal.features import android.content.Context +import android.content.SharedPreferences +import android.os.Build import android.util.Log import com.onesignal.features.IStartupFlagsStore import com.onesignal.features.StartupFlags @@ -63,11 +65,18 @@ internal class PrefsStartupFlagsStore( check(prefs().edit().putStringSet(KEY_ENABLED, keys).commit()) { "startup flags commit failed" } } - private fun prefs() = context.applicationContext.getSharedPreferences(STORE, Context.MODE_PRIVATE) + private fun prefs(): SharedPreferences = storageContext(context).getSharedPreferences(STORE, Context.MODE_PRIVATE) - private companion object { + internal companion object { const val STORE = "OneSignalStartupFlags" - const val KEY_ENABLED = "enabled" - const val TAG = "OneSignal" + private const val KEY_ENABLED = "enabled" + private const val TAG = "OneSignal" + + // Device-protected so receivers and jobs that start before first unlock read the real values. + // Must hold nothing secret: this storage is readable while the device is still locked. + fun storageContext(context: Context): Context { + val app = context.applicationContext + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) app.createDeviceProtectedStorageContext() else app + } } } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt index 5fb88611ef..290c09f902 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt @@ -2,6 +2,7 @@ package com.onesignal.core.internal.features import android.content.Context import android.content.SharedPreferences +import android.os.Build import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.common.modeling.ModelChangeTags @@ -21,8 +22,10 @@ import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk import org.json.JSONArray +import org.robolectric.annotation.Config @RobolectricTest +@Config(sdk = [Build.VERSION_CODES.O]) class StartupFlagsCacheTests : FunSpec({ val logging = FeatureFlag.SDK_CUSTOM_LOGGING val key = logging.key @@ -33,7 +36,7 @@ class StartupFlagsCacheTests : FunSpec({ } test("a missing file is off") { - appContext().getSharedPreferences("OneSignalStartupFlags", Context.MODE_PRIVATE).edit().clear().commit() + startupPrefs().edit().clear().commit() val flags = StartupFlagsCache.readFile(appContext()) flags.source shouldBe StartupFlagsSource.MISSING @@ -44,6 +47,7 @@ class StartupFlagsCacheTests : FunSpec({ val context = mockk() val prefs = mockk() every { context.applicationContext } returns context + every { context.createDeviceProtectedStorageContext() } returns context every { context.getSharedPreferences(any(), any()) } returns prefs every { prefs.getStringSet(any(), any()) } returns null every { prefs.edit().putStringSet(any(), any()).commit() } returns false @@ -84,9 +88,15 @@ class StartupFlagsCacheTests : FunSpec({ test("only APP_STARTUP catalog keys reach the file") { val context = appContext() StartupFlagsCache.write(context, listOf(key, FeatureFlag.SDK_IDENTITY_VERIFICATION.key, "not_in_catalog")) - context - .getSharedPreferences("OneSignalStartupFlags", Context.MODE_PRIVATE) - .getStringSet("enabled", null) shouldBe setOf(key) + startupPrefs().getStringSet("enabled", null) shouldBe setOf(key) + } + + test("the file lives in device-protected storage, so it is readable before first unlock") { + val context = appContext() + StartupFlagsCache.write(context, listOf(key)) + + startupPrefs().getStringSet("enabled", null) shouldBe setOf(key) + context.getSharedPreferences(PrefsStartupFlagsStore.STORE, Context.MODE_PRIVATE).getStringSet("enabled", null) shouldBe null } test("reading an IMMEDIATE flag is rejected") { @@ -104,6 +114,7 @@ class StartupFlagsCacheTests : FunSpec({ test("an unreadable file is off") { val broken = mockk() every { broken.applicationContext } returns broken + every { broken.createDeviceProtectedStorageContext() } returns broken every { broken.getSharedPreferences(any(), any()) } throws SecurityException("prefs unavailable") val flags = StartupFlagsCache.readFile(broken) @@ -166,6 +177,9 @@ class StartupFlagsCacheTests : FunSpec({ private fun appContext(): Context = ApplicationProvider.getApplicationContext() +private fun startupPrefs(): SharedPreferences = + appContext().createDeviceProtectedStorageContext().getSharedPreferences(PrefsStartupFlagsStore.STORE, Context.MODE_PRIVATE) + private fun clearStores() { val context = appContext() StartupFlagsCache.write(context, emptyList()) From b43dcd82db0655f7b753058054ebec170adbbcf7 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 21 Oct 2026 12:18:33 -0500 Subject: [PATCH 04/15] test: [SDK-5581] a locked boot still reads startup flags 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 --- .../features/StartupFlagsCacheTests.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt index 290c09f902..f051361e21 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt @@ -99,6 +99,24 @@ class StartupFlagsCacheTests : FunSpec({ context.getSharedPreferences(PrefsStartupFlagsStore.STORE, Context.MODE_PRIVATE).getStringSet("enabled", null) shouldBe null } + test("a locked boot still reads the flags") { + val unlockedOnly = mockk() + every { unlockedOnly.getStringSet(any(), any()) } throws IllegalStateException("credential storage locked") + val deviceProtected = mockk() + every { deviceProtected.getStringSet(any(), any()) } returns setOf(key) + val dpContext = mockk() + every { dpContext.getSharedPreferences(any(), any()) } returns deviceProtected + val locked = mockk() + every { locked.applicationContext } returns locked + every { locked.getSharedPreferences(any(), any()) } returns unlockedOnly + every { locked.createDeviceProtectedStorageContext() } returns dpContext + + val flags = StartupFlagsCache.readFile(locked) + + flags.source shouldBe StartupFlagsSource.STORE + flags.isEnabled(logging) shouldBe true + } + test("reading an IMMEDIATE flag is rejected") { shouldThrow { StartupFlagsCache.readFile(appContext()).isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) From 8fb7974d0b943dc057232d701076a3ca272eb748 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 21 Oct 2026 14:14:06 -0500 Subject: [PATCH 05/15] fix: [SDK-5581] serialize startup flag mirrors and read the latest flags 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 --- .../core/internal/features/FeatureManager.kt | 20 +++++-------- .../core/internal/features/README.md | 6 +++- .../internal/features/StartupFlagsCache.kt | 10 ++++++- .../features/StartupFlagsCacheTests.kt | 29 +++++++++++++++++++ 4 files changed, 51 insertions(+), 14 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt index e32fcc4fad..806240b15c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt @@ -43,13 +43,8 @@ interface IFeatureManager { } /** - * Android host for the shared [SharedFeatureManager] latch. - * - * Persistence ([ConfigModel]), the startup flags file, and store subscriptions stay here. - * Catalog, activation modes, startup snapshot, and latching live in KMP: this host calls - * [SharedFeatureManager.refreshAtStartup] once, then [SharedFeatureManager.refresh] after fetches. - * - * Shared [SharedFeatureManager] is thread-safe; this host does not add an extra lock. + * Android host for the shared [SharedFeatureManager]: owns [ConfigModel] persistence, the startup + * flags file, and store subscriptions. Latching lives in KMP. See README.md in this package. */ @Suppress("TooGenericExceptionCaught") internal class FeatureManager( @@ -81,7 +76,7 @@ internal class FeatureManager( override fun start() { started = true - mirrorStartupFlags(configModelStore.model) + mirrorStartupFlags() } override fun isEnabled(feature: FeatureFlag): Boolean = latch.isEnabled(feature) @@ -102,7 +97,7 @@ internal class FeatureManager( tag: String, ) { Logging.debug("OneSignal: FeatureManager.onModelReplaced(tag=$tag)") - if (started) mirrorStartupFlags(model) + if (started) mirrorStartupFlags() if (tag == ModelChangeTags.HYDRATE || tag == ModelChangeTags.NORMAL) { try { refreshFrom(model) @@ -122,7 +117,7 @@ internal class FeatureManager( ) { Logging.debug("OneSignal: FeatureManager.onModelUpdated(property=${args.property}, tag=$tag)") if (started && args.property == ConfigModel::sdkRemoteFeatureFlags.name) { - mirrorStartupFlags(configModelStore.model) + mirrorStartupFlags() } try { refreshFrom(configModelStore.model) @@ -145,9 +140,10 @@ internal class FeatureManager( } } - private fun mirrorStartupFlags(model: ConfigModel) { + // Reads the store's model inside the write lock: a list captured by the caller could be stale. + private fun mirrorStartupFlags() { try { - StartupFlagsCache.write(applicationService.appContext, model.sdkRemoteFeatureFlags) + StartupFlagsCache.write(applicationService.appContext) { configModelStore.model.sdkRemoteFeatureFlags } } catch (t: Throwable) { Logging.warn("OneSignal: failed to mirror startup flags: ${t.message}", t) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md index 9014e8d9c6..cecba7f94c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md @@ -12,7 +12,8 @@ choice that lives for the whole process, such as which thread pools or logging p - After init (anything with an injected `IFeatureManager`): `featureManager.isEnabled(flag)`. - Before init (no service container yet): `FeatureManager.startupFlags { context }.isEnabled(flag)`. -Nothing else. Both calls return the same value for the life of the process. +Nothing else. Both calls return the same value for the life of the process, unless +`FeatureManager.localFeatureOverrides` is set: local overrides only reach `isEnabled` (see Tests). ## Where the logic lives @@ -51,6 +52,9 @@ process-wide holder (`StartupFlagsCache`). is used. Never put anything secret in this file: it is readable while the device is locked. - **One launch behind after an upgrade.** The file does not exist on the first launch of the version that introduces it, so APP_STARTUP flags are off for that launch even if the store already has them. +- **Writes are serialized and read the latest flags.** `start()` and a flags fetch can mirror at the + same time. Each write reads `ConfigModel` inside the write lock, so the last write to finish always + holds the newest flags, never a list captured before another write landed. - **No writes before `start()`.** `FeatureManager` can be constructed on main; the file is only written after start. - **Main thread cost.** The first reader opens one small XML file. Cheap, but StrictMode can still report diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt index c249d29c29..8a29d92b07 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt @@ -19,12 +19,20 @@ internal object StartupFlagsCache { /** The first caller in the process reads the file; everyone after gets the same answer. */ fun snapshot(context: () -> Context?): StartupFlags = latch.snapshot { context()?.let(::PrefsStartupFlagsStore) } + private val writeLock = Any() + /** Mirrors the APP_STARTUP subset of [remoteFlags] for the next process. Call off the main thread. */ fun write( context: Context, remoteFlags: List, + ) = write(context) { remoteFlags } + + /** [currentFlags] runs under the write lock, so racing writers always leave the latest flags on disk. */ + fun write( + context: Context, + currentFlags: () -> List, ) { - latch.mirror(PrefsStartupFlagsStore(context), remoteFlags) + synchronized(writeLock) { latch.mirror(PrefsStartupFlagsStore(context), currentFlags()) } } /** Reads the file now, without touching this process's snapshot. */ diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt index f051361e21..ca65f1f577 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt @@ -23,6 +23,9 @@ import io.mockk.every import io.mockk.mockk import org.json.JSONArray import org.robolectric.annotation.Config +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread @RobolectricTest @Config(sdk = [Build.VERSION_CODES.O]) @@ -85,6 +88,32 @@ class StartupFlagsCacheTests : FunSpec({ StartupFlagsCache.readFile(context).isEnabled(logging) shouldBe false } + test("racing writes leave the newest flags on disk") { + val context = appContext() + val current = AtomicReference(listOf(key)) + val slowHasRead = CountDownLatch(1) + val releaseSlow = CountDownLatch(1) + + val slow = + thread { + StartupFlagsCache.write(context) { + val read = current.get() + slowHasRead.countDown() + releaseSlow.await() + read + } + } + slowHasRead.await() + current.set(emptyList()) + val fast = thread { StartupFlagsCache.write(context) { current.get() } } + fast.join(200) + releaseSlow.countDown() + slow.join() + fast.join() + + StartupFlagsCache.readFile(context).isEnabled(logging) shouldBe false + } + test("only APP_STARTUP catalog keys reach the file") { val context = appContext() StartupFlagsCache.write(context, listOf(key, FeatureFlag.SDK_IDENTITY_VERIFICATION.key, "not_in_catalog")) From 1d32515c8ace91ac900ef2b25ee05a2239d542ca Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 21 Oct 2026 14:14:06 -0500 Subject: [PATCH 06/15] chore: [SDK-5581] pin KMP to the Swift-safe startup flags store Co-authored-by: Cursor --- OneSignal-KMP-SDK | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index 64a8671fea..8f5e5d8fda 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit 64a8671fea7b0169adf12c4bd19262fb5af3ea88 +Subproject commit 8f5e5d8fda6d9bb834300b49ae142335599eb5d5 From 5e2a34f88999cda2c3a31266a37b7754ac231b86 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 21 Oct 2026 15:29:51 -0500 Subject: [PATCH 07/15] fix: [SDK-5581] follow KMP startup flags review fixes - 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 --- OneSignal-KMP-SDK | 2 +- .../java/com/onesignal/core/internal/features/README.md | 7 ++++--- .../core/internal/features/StartupFlagsCache.kt | 6 ++---- .../core/internal/features/StartupFlagsCacheTests.kt | 9 +++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index 8f5e5d8fda..05c808f891 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit 8f5e5d8fda6d9bb834300b49ae142335599eb5d5 +Subproject commit 05c808f891d59e2b3127609758b59ae01892388a diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md index cecba7f94c..a3f96ef79b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md @@ -37,7 +37,7 @@ process-wide holder (`StartupFlagsCache`). ## Rules - **Only APP_STARTUP flags.** IMMEDIATE flags change mid-run and the file lags the store, so they are - never written and `StartupFlags.isEnabled` rejects them. + never written and `StartupFlags.isEnabled` returns false for them. - **The snapshot is the process value.** Telemetry tags and `isEnabled` must match what early code latched, so APP_STARTUP flags never come from the store at runtime. - **Missing or unreadable means off.** A missing file, a read that throws, or no application context @@ -53,8 +53,9 @@ process-wide holder (`StartupFlagsCache`). - **One launch behind after an upgrade.** The file does not exist on the first launch of the version that introduces it, so APP_STARTUP flags are off for that launch even if the store already has them. - **Writes are serialized and read the latest flags.** `start()` and a flags fetch can mirror at the - same time. Each write reads `ConfigModel` inside the write lock, so the last write to finish always - holds the newest flags, never a list captured before another write landed. + same time. Each write reads `ConfigModel` inside the latch's mirror lock, so the last write to finish + always holds the newest flags, never a list captured before another write landed. A file that can't + be read is rewritten rather than skipped. - **No writes before `start()`.** `FeatureManager` can be constructed on main; the file is only written after start. - **Main thread cost.** The first reader opens one small XML file. Cheap, but StrictMode can still report diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt index 8a29d92b07..c921d81de7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt @@ -19,20 +19,18 @@ internal object StartupFlagsCache { /** The first caller in the process reads the file; everyone after gets the same answer. */ fun snapshot(context: () -> Context?): StartupFlags = latch.snapshot { context()?.let(::PrefsStartupFlagsStore) } - private val writeLock = Any() - /** Mirrors the APP_STARTUP subset of [remoteFlags] for the next process. Call off the main thread. */ fun write( context: Context, remoteFlags: List, ) = write(context) { remoteFlags } - /** [currentFlags] runs under the write lock, so racing writers always leave the latest flags on disk. */ + /** [currentFlags] runs under the latch's mirror lock, so racing writers always leave the latest flags on disk. */ fun write( context: Context, currentFlags: () -> List, ) { - synchronized(writeLock) { latch.mirror(PrefsStartupFlagsStore(context), currentFlags()) } + latch.mirror(PrefsStartupFlagsStore(context), currentFlags) } /** Reads the file now, without touching this process's snapshot. */ diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt index ca65f1f577..96f7b9424d 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt @@ -146,10 +146,11 @@ class StartupFlagsCacheTests : FunSpec({ flags.isEnabled(logging) shouldBe true } - test("reading an IMMEDIATE flag is rejected") { - shouldThrow { - StartupFlagsCache.readFile(appContext()).isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) - } + test("an IMMEDIATE flag is off even if its key is in the file") { + val immediate = FeatureFlag.SDK_IDENTITY_VERIFICATION + startupPrefs().edit().putStringSet("enabled", setOf(immediate.key)).commit() + + StartupFlagsCache.readFile(appContext()).isEnabled(immediate) shouldBe false } test("read ignores the OneSignal config store") { From 6091626d9690c08217158a66087d2afef2c7b632 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 21 Oct 2026 15:41:51 -0500 Subject: [PATCH 08/15] test: [SDK-5581] an unreadable startup flags file is rewritten Co-authored-by: Cursor --- .../internal/features/StartupFlagsCache.kt | 2 +- .../features/StartupFlagsCacheTests.kt | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt index c921d81de7..7da0bf2da3 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt @@ -62,7 +62,7 @@ internal class PrefsStartupFlagsStore( prefs().getStringSet(KEY_ENABLED, null)?.toSet() } catch (t: Throwable) { // android.util.Log, not Logging: Logging can dispatch, and this can run before SDK init. - Log.w(TAG, "startup flags unreadable, treating all as off", t) + Log.w(TAG, "startup flags unreadable", t) throw t } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt index 96f7b9424d..5f39398c94 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt @@ -21,6 +21,7 @@ import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk +import io.mockk.verify import org.json.JSONArray import org.robolectric.annotation.Config import java.util.concurrent.CountDownLatch @@ -171,6 +172,23 @@ class StartupFlagsCacheTests : FunSpec({ flags.isEnabled(logging) shouldBe false } + test("an unreadable file is rewritten") { + val context = mockk() + val prefs = mockk() + val editor = mockk() + every { context.applicationContext } returns context + every { context.createDeviceProtectedStorageContext() } returns context + every { context.getSharedPreferences(any(), any()) } returns prefs + every { prefs.getStringSet(any(), any()) } throws ClassCastException("not a string set") + every { prefs.edit() } returns editor + every { editor.putStringSet(any(), any()) } returns editor + every { editor.commit() } returns true + + StartupFlagsCache.write(context, listOf(key)) + + verify { editor.putStringSet("enabled", setOf(key)) } + } + test("no application context is off") { StartupFlagsCache.readFile(null).source shouldBe StartupFlagsSource.NO_STORE } From c4e0a57e3c0ac05877891c17aaec89b954741238 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 22 Oct 2026 08:44:05 -0500 Subject: [PATCH 09/15] chore: [SDK-5581] pin KMP to the main merge of the startup flags latch Co-authored-by: Cursor --- OneSignal-KMP-SDK | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index 05c808f891..35d538aace 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit 05c808f891d59e2b3127609758b59ae01892388a +Subproject commit 35d538aacecc28b493bb53b83c2b2e31085a46a2 From 13522d305cafee6672c81e88a4d3d5689e16cf4b Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 22 Oct 2026 08:46:57 -0500 Subject: [PATCH 10/15] fix: [SDK-5581] address startup flags review - 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 --- .../core/internal/features/FeatureManager.kt | 3 +- .../core/internal/features/README.md | 11 ++-- .../internal/features/StartupFlagsCache.kt | 39 ++++++++++---- .../features/StartupFlagsCacheTests.kt | 54 +++++++++++++++---- 4 files changed, 84 insertions(+), 23 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt index 806240b15c..ad2404ed4d 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt @@ -53,7 +53,7 @@ internal class FeatureManager( ) : IFeatureManager, IStartableService, ISingletonModelStoreChangeHandler { private val latch = SharedFeatureManager() - // Mirror writes wait for start(), which runs off main, so construction on main never writes. + // Construction can run on main, so mirror writes wait for start(), which OneSignalImp calls off main. @Volatile private var started = false @@ -65,6 +65,7 @@ internal class FeatureManager( // APP_STARTUP flags come from the snapshot pre-init code also latched from, so telemetry // never reports a startup flag the running process is not using. val startup = StartupFlagsCache.snapshot { appContextOrNull() } + Logging.debug("OneSignal: startup flags source=${startup.source} enabled=${startup.keys}") try { logDeferred(latch.refreshAtStartup(configModelStore.model.sdkRemoteFeatureFlags, startup, localFeatureOverrides)) } catch (t: Throwable) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md index a3f96ef79b..ac305ff60a 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md @@ -41,8 +41,12 @@ process-wide holder (`StartupFlagsCache`). - **The snapshot is the process value.** Telemetry tags and `isEnabled` must match what early code latched, so APP_STARTUP flags never come from the store at runtime. - **Missing or unreadable means off.** A missing file, a read that throws, or no application context - all give an empty snapshot. The source (`STORE`, `MISSING`, `UNREADABLE`, `NO_STORE`) is kept for - logging. + all give an empty snapshot. The source (`STORE`, `MISSING`, `UNREADABLE`, `NO_STORE`) is logged at + debug when `FeatureManager` is built. +- **The process Application is the fallback context.** When the caller has no context yet (the + startup provider is disabled, or this is not the default process), the snapshot uses + `ActivityThread.currentApplication()`. `NO_STORE` only happens when that is missing too, and logs a + warning. - **One value per process, even when unreadable.** The first read wins, including an `UNREADABLE` one. Later readers in the same process (sync jobs, FCM receivers, prewarm) never re-read and get a different answer, so code that already latched "off" never disagrees with code that runs later. @@ -55,7 +59,8 @@ process-wide holder (`StartupFlagsCache`). - **Writes are serialized and read the latest flags.** `start()` and a flags fetch can mirror at the same time. Each write reads `ConfigModel` inside the latch's mirror lock, so the last write to finish always holds the newest flags, never a list captured before another write landed. A file that can't - be read is rewritten rather than skipped. + be read is rewritten rather than skipped, and so is one whose last `commit()` failed, since + `SharedPreferences` keeps the uncommitted value in memory. - **No writes before `start()`.** `FeatureManager` can be constructed on main; the file is only written after start. - **Main thread cost.** The first reader opens one small XML file. Cheap, but StrictMode can still report diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt index 7da0bf2da3..36bc93a6f7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt @@ -17,15 +17,18 @@ internal object StartupFlagsCache { private var latch = StartupFlagsLatch() /** The first caller in the process reads the file; everyone after gets the same answer. */ - fun snapshot(context: () -> Context?): StartupFlags = latch.snapshot { context()?.let(::PrefsStartupFlagsStore) } + fun snapshot(context: () -> Context?): StartupFlags = latch.snapshot { storeFor(context() ?: currentApplication()) } - /** Mirrors the APP_STARTUP subset of [remoteFlags] for the next process. Call off the main thread. */ - fun write( - context: Context, - remoteFlags: List, - ) = write(context) { remoteFlags } + // The answer is latched for the whole process, so a missing context must be visible in logcat. + private fun storeFor(context: Context?): IStartupFlagsStore? { + if (context == null) Log.w(PrefsStartupFlagsStore.TAG, "startup flags: no application context, all off for this process") + return context?.let(::PrefsStartupFlagsStore) + } - /** [currentFlags] runs under the latch's mirror lock, so racing writers always leave the latest flags on disk. */ + /** + * Mirrors the APP_STARTUP subset of [currentFlags] for the next process. Call off the main thread. + * [currentFlags] runs under the latch's mirror lock, so racing writers always leave the latest flags on disk. + */ fun write( context: Context, currentFlags: () -> List, @@ -38,6 +41,7 @@ internal object StartupFlagsCache { /** Null forgets the snapshot, as a new process would. */ internal fun setSnapshotForTest(flags: StartupFlags?) { + PrefsStartupFlagsStore.commitFailed = false latch = StartupFlagsLatch().also { fresh -> if (flags != null) fresh.snapshot { FixedStore(flags.keys) } @@ -59,6 +63,7 @@ internal class PrefsStartupFlagsStore( @Suppress("TooGenericExceptionCaught") override fun read(): Set? = try { + check(!commitFailed) { "last startup flags commit failed" } prefs().getStringSet(KEY_ENABLED, null)?.toSet() } catch (t: Throwable) { // android.util.Log, not Logging: Logging can dispatch, and this can run before SDK init. @@ -68,7 +73,9 @@ internal class PrefsStartupFlagsStore( // commit, not apply: the next process reads this at startup, so it must be on disk when we return. override fun write(keys: Set) { - check(prefs().edit().putStringSet(KEY_ENABLED, keys).commit()) { "startup flags commit failed" } + val committed = prefs().edit().putStringSet(KEY_ENABLED, keys).commit() + commitFailed = !committed + check(committed) { "startup flags commit failed" } } private fun prefs(): SharedPreferences = storageContext(context).getSharedPreferences(STORE, Context.MODE_PRIVATE) @@ -76,7 +83,11 @@ internal class PrefsStartupFlagsStore( internal companion object { const val STORE = "OneSignalStartupFlags" private const val KEY_ENABLED = "enabled" - private const val TAG = "OneSignal" + const val TAG = "OneSignal" + + // A failed commit still updates the in-memory prefs, so reads can't be trusted until a commit lands. + @Volatile + var commitFailed = false // Device-protected so receivers and jobs that start before first unlock read the real values. // Must hold nothing secret: this storage is readable while the device is still locked. @@ -86,3 +97,13 @@ internal class PrefsStartupFlagsStore( } } } + +/** The process Application, for when the startup provider is disabled or this is not the default process. */ +@Suppress("TooGenericExceptionCaught", "SwallowedException") +internal fun currentApplication(): Context? = + try { + val method = Class.forName("android.app.ActivityThread").getMethod("currentApplication") + method.invoke(null) as? Context + } catch (_: Throwable) { + null + } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt index 5f39398c94..c1b15a7196 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt @@ -56,7 +56,7 @@ class StartupFlagsCacheTests : FunSpec({ every { prefs.getStringSet(any(), any()) } returns null every { prefs.edit().putStringSet(any(), any()).commit() } returns false - shouldThrow { StartupFlagsCache.write(context, listOf(key)) } + shouldThrow { StartupFlagsCache.write(context) { listOf(key) } } } test("turning a flag off remotely reaches the next process") { @@ -83,9 +83,9 @@ class StartupFlagsCacheTests : FunSpec({ test("the file follows the remote flags, regardless of case") { val context = appContext() - StartupFlagsCache.write(context, listOf("SDK_Custom_Logging")) + StartupFlagsCache.write(context) { listOf("SDK_Custom_Logging") } StartupFlagsCache.readFile(context).isEnabled(logging) shouldBe true - StartupFlagsCache.write(context, listOf(FeatureFlag.SDK_IDENTITY_VERIFICATION.key)) + StartupFlagsCache.write(context) { listOf(FeatureFlag.SDK_IDENTITY_VERIFICATION.key) } StartupFlagsCache.readFile(context).isEnabled(logging) shouldBe false } @@ -117,13 +117,13 @@ class StartupFlagsCacheTests : FunSpec({ test("only APP_STARTUP catalog keys reach the file") { val context = appContext() - StartupFlagsCache.write(context, listOf(key, FeatureFlag.SDK_IDENTITY_VERIFICATION.key, "not_in_catalog")) + StartupFlagsCache.write(context) { listOf(key, FeatureFlag.SDK_IDENTITY_VERIFICATION.key, "not_in_catalog") } startupPrefs().getStringSet("enabled", null) shouldBe setOf(key) } test("the file lives in device-protected storage, so it is readable before first unlock") { val context = appContext() - StartupFlagsCache.write(context, listOf(key)) + StartupFlagsCache.write(context) { listOf(key) } startupPrefs().getStringSet("enabled", null) shouldBe setOf(key) context.getSharedPreferences(PrefsStartupFlagsStore.STORE, Context.MODE_PRIVATE).getStringSet("enabled", null) shouldBe null @@ -184,7 +184,7 @@ class StartupFlagsCacheTests : FunSpec({ every { editor.putStringSet(any(), any()) } returns editor every { editor.commit() } returns true - StartupFlagsCache.write(context, listOf(key)) + StartupFlagsCache.write(context) { listOf(key) } verify { editor.putStringSet("enabled", setOf(key)) } } @@ -195,11 +195,11 @@ class StartupFlagsCacheTests : FunSpec({ test("the snapshot is read once per process") { val context = appContext() - StartupFlagsCache.write(context, listOf(key)) + StartupFlagsCache.write(context) { listOf(key) } FeatureManager.resetStartupFlagsForTest() FeatureManager.startupFlags { context }.isEnabled(logging) shouldBe true - StartupFlagsCache.write(context, emptyList()) + StartupFlagsCache.write(context) { emptyList() } FeatureManager.startupFlags { context }.isEnabled(logging) shouldBe true FeatureManager.resetStartupFlagsForTest() @@ -208,7 +208,7 @@ class StartupFlagsCacheTests : FunSpec({ test("FeatureManager reports the snapshot, not the store, when they disagree") { val context = appContext() - StartupFlagsCache.write(context, emptyList()) + StartupFlagsCache.write(context) { emptyList() } FeatureManager.startupFlags { context }.isEnabled(logging) shouldBe false val store = MockHelper.configModelStore { it.sdkRemoteFeatureFlags = listOf(key) } @@ -220,6 +220,40 @@ class StartupFlagsCacheTests : FunSpec({ manager.enabledFeatureKeys() shouldBe emptyList() } + test("FeatureManager built before the app context is set still reads the file") { + StartupFlagsCache.write(appContext()) { listOf(key) } + FeatureManager.resetStartupFlagsForTest() + val applicationService = MockHelper.applicationService() + every { applicationService.appContext } throws NullPointerException("not started") + + val manager = FeatureManager(MockHelper.configModelStore { it.sdkRemoteFeatureFlags = listOf(key) }, applicationService) + + manager.isEnabled(logging) shouldBe true + FeatureManager.startupFlags { null }.source shouldBe StartupFlagsSource.STORE + } + + test("after a failed commit the next write retries, even with the same flags") { + val context = mockk() + val prefs = mockk() + val editor = mockk() + var inMemory: Set? = null + var pending: Set? = null + val commits = ArrayDeque(listOf(false, true)) + every { context.applicationContext } returns context + every { context.createDeviceProtectedStorageContext() } returns context + every { context.getSharedPreferences(any(), any()) } returns prefs + every { prefs.getStringSet(any(), any()) } answers { inMemory } + every { prefs.edit() } returns editor + every { editor.putStringSet(any(), any()) } answers { pending = secondArg(); editor } + // Like SharedPreferences, a failed commit still updates the in-memory copy. + every { editor.commit() } answers { inMemory = pending; commits.removeFirst() } + + shouldThrow { StartupFlagsCache.write(context) { listOf(key) } } + StartupFlagsCache.write(context) { listOf(key) } + + verify(exactly = 2) { editor.commit() } + } + test("FeatureManager mirrors the cached flags on start and on later updates, never before start") { val context = appContext() val store = MockHelper.configModelStore { it.sdkRemoteFeatureFlags = listOf(key) } @@ -248,7 +282,7 @@ private fun startupPrefs(): SharedPreferences = private fun clearStores() { val context = appContext() - StartupFlagsCache.write(context, emptyList()) + StartupFlagsCache.write(context) { emptyList() } context .getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) .edit() From 52dd35bf2c4ae76147fa8867bc2578ddb29ef591 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 22 Oct 2026 09:33:40 -0500 Subject: [PATCH 11/15] refactor: [SDK-5581] note the application context instead of reflecting 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 --- .../java/com/onesignal/common/AppContext.kt | 30 +++++++++++++++++++ .../impl/ActivityLifecycleInitializer.kt | 2 ++ .../core/internal/features/README.md | 8 ++--- .../internal/features/StartupFlagsCache.kt | 13 ++------ .../com/onesignal/internal/OneSignalImp.kt | 3 ++ .../impl/ActivityLifecycleInitializerTests.kt | 13 ++++++++ .../features/StartupFlagsCacheTests.kt | 12 +++++++- 7 files changed, 65 insertions(+), 16 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AppContext.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AppContext.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AppContext.kt new file mode 100644 index 0000000000..9f0615170b --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AppContext.kt @@ -0,0 +1,30 @@ +package com.onesignal.common + +import android.content.Context + +/** + * The process's application Context, noted by SDK entry points, so code that runs before init + * (or before ApplicationService starts) can read app storage without a Context of its own. + */ +internal object AppContext { + @Volatile + private var context: Context? = null + + fun get(): Context? = context + + /** First caller wins. Only the application Context is kept, so an Activity never leaks here. */ + @Suppress("TooGenericExceptionCaught", "SwallowedException") + fun note(context: Context) { + if (this.context != null) return + this.context = + try { + context.applicationContext + } catch (_: Throwable) { + null + } + } + + internal fun resetForTest() { + context = null + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializer.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializer.kt index 9fde58fc5b..e910e4fa67 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializer.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializer.kt @@ -3,6 +3,7 @@ package com.onesignal.core.internal.application.impl import android.app.Application import android.content.Context import androidx.startup.Initializer +import com.onesignal.common.AppContext import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.core.internal.application.IApplicationService @@ -21,6 +22,7 @@ import com.onesignal.core.internal.application.IApplicationService */ class ActivityLifecycleInitializer : Initializer { override fun create(context: Context): IApplicationService { + AppContext.note(context) OneSignalDispatchers.prewarm() val applicationService = ApplicationService.getInstance() (context.applicationContext as? Application)?.let { application -> diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md index ac305ff60a..43e4c331a0 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md @@ -43,10 +43,10 @@ process-wide holder (`StartupFlagsCache`). - **Missing or unreadable means off.** A missing file, a read that throws, or no application context all give an empty snapshot. The source (`STORE`, `MISSING`, `UNREADABLE`, `NO_STORE`) is logged at debug when `FeatureManager` is built. -- **The process Application is the fallback context.** When the caller has no context yet (the - startup provider is disabled, or this is not the default process), the snapshot uses - `ActivityThread.currentApplication()`. `NO_STORE` only happens when that is missing too, and logs a - warning. +- **SDK entry points note the context.** When the caller has no context yet (for example, init's + first log builds `FeatureManager` before `ApplicationService` starts), the snapshot uses the one in + `AppContext`, noted by the startup initializer and at the start of init. No reflection: if nothing + noted one, the snapshot is `NO_STORE` and logs a warning. - **One value per process, even when unreadable.** The first read wins, including an `UNREADABLE` one. Later readers in the same process (sync jobs, FCM receivers, prewarm) never re-read and get a different answer, so code that already latched "off" never disagrees with code that runs later. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt index 36bc93a6f7..02742d5131 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.SharedPreferences import android.os.Build import android.util.Log +import com.onesignal.common.AppContext import com.onesignal.features.IStartupFlagsStore import com.onesignal.features.StartupFlags import com.onesignal.features.StartupFlagsLatch @@ -17,7 +18,7 @@ internal object StartupFlagsCache { private var latch = StartupFlagsLatch() /** The first caller in the process reads the file; everyone after gets the same answer. */ - fun snapshot(context: () -> Context?): StartupFlags = latch.snapshot { storeFor(context() ?: currentApplication()) } + fun snapshot(context: () -> Context?): StartupFlags = latch.snapshot { storeFor(context() ?: AppContext.get()) } // The answer is latched for the whole process, so a missing context must be visible in logcat. private fun storeFor(context: Context?): IStartupFlagsStore? { @@ -97,13 +98,3 @@ internal class PrefsStartupFlagsStore( } } } - -/** The process Application, for when the startup provider is disabled or this is not the default process. */ -@Suppress("TooGenericExceptionCaught", "SwallowedException") -internal fun currentApplication(): Context? = - try { - val method = Class.forName("android.app.ActivityThread").getMethod("currentApplication") - method.invoke(null) as? Context - } catch (_: Throwable) { - null - } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index 7325c27971..56f11bd106 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -10,6 +10,7 @@ import com.onesignal.OneSignalError import com.onesignal.OneSignalResult import com.onesignal.OneSignalUserProfile import com.onesignal.common.AndroidUtils +import com.onesignal.common.AppContext import com.onesignal.common.DeviceUtils import com.onesignal.common.OneSignalUtils import com.onesignal.common.isMissing @@ -247,6 +248,8 @@ internal class OneSignalImp : IOneSignal, } private fun initEssentials(context: Context) { + // Before the logger: its first log can build FeatureManager, whose startup flags need a context. + AppContext.note(context) // LoggerLifecycleManager comes up early so crash handling and remote logging can capture // anything that happens during the rest of init. FeatureManager is wired in via a // lazy supplier — `enabledFeatureFlags` is read per-event, so resolving the manager diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializerTests.kt index d6774628c6..d23c11fd49 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializerTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializerTests.kt @@ -2,8 +2,10 @@ package com.onesignal.core.internal.application.impl import android.app.Application import android.content.Context +import com.onesignal.common.AppContext import com.onesignal.common.threading.OneSignalDispatchers import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -27,11 +29,13 @@ class ActivityLifecycleInitializerTests : FunSpec({ mockkObject(OneSignalDispatchers) every { OneSignalDispatchers.prewarm() } returns Unit resetSharedApplicationService() + AppContext.resetForTest() } afterTest { unmockkObject(OneSignalDispatchers) resetSharedApplicationService() + AppContext.resetForTest() } test("create prewarms before registering activity lifecycle callbacks") { @@ -50,6 +54,15 @@ class ActivityLifecycleInitializerTests : FunSpec({ } } + test("create notes the application context for code that runs before init") { + val application = mockk(relaxed = true) + every { application.applicationContext } returns application + + ActivityLifecycleInitializer().create(application) + + AppContext.get() shouldBe application + } + test("create prewarms even when application context is unavailable") { val context = mockk(relaxed = true) every { context.applicationContext } returns context diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt index c1b15a7196..f312c80006 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt @@ -5,6 +5,7 @@ import android.content.SharedPreferences import android.os.Build import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.common.AppContext import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.common.modeling.ModelChangedArgs import com.onesignal.core.internal.config.CONFIG_NAME_SPACE @@ -37,6 +38,7 @@ class StartupFlagsCacheTests : FunSpec({ afterTest { clearStores() FeatureManager.resetStartupFlagsForTest() + AppContext.resetForTest() } test("a missing file is off") { @@ -220,9 +222,10 @@ class StartupFlagsCacheTests : FunSpec({ manager.enabledFeatureKeys() shouldBe emptyList() } - test("FeatureManager built before the app context is set still reads the file") { + test("FeatureManager built before ApplicationService starts reads the file through the noted context") { StartupFlagsCache.write(appContext()) { listOf(key) } FeatureManager.resetStartupFlagsForTest() + AppContext.note(appContext()) val applicationService = MockHelper.applicationService() every { applicationService.appContext } throws NullPointerException("not started") @@ -232,6 +235,13 @@ class StartupFlagsCacheTests : FunSpec({ FeatureManager.startupFlags { null }.source shouldBe StartupFlagsSource.STORE } + test("with no context anywhere the snapshot is off rather than guessed") { + StartupFlagsCache.write(appContext()) { listOf(key) } + FeatureManager.resetStartupFlagsForTest() + + FeatureManager.startupFlags { null }.source shouldBe StartupFlagsSource.NO_STORE + } + test("after a failed commit the next write retries, even with the same flags") { val context = mockk() val prefs = mockk() From 5d81c365532e2d157b9f8f438ea892b263450643 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 22 Oct 2026 10:33:12 -0500 Subject: [PATCH 12/15] docs: [SDK-5581] startup flag writes stay off main because of their callers Co-authored-by: Cursor --- .../com/onesignal/core/internal/features/FeatureManager.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt index ad2404ed4d..29cb55f6c0 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt @@ -53,7 +53,8 @@ internal class FeatureManager( ) : IFeatureManager, IStartableService, ISingletonModelStoreChangeHandler { private val latch = SharedFeatureManager() - // Construction can run on main, so mirror writes wait for start(), which OneSignalImp calls off main. + // Writes wait for start() and run on the caller's thread, so staying off main is caller-dependent: + // StartupService runs start() on the default lane, and the flag fetches update ConfigModel on IO. @Volatile private var started = false From 2fa086b9a4343c2c39c3d04e8d00ab0ccf086351 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 22 Oct 2026 12:32:54 -0500 Subject: [PATCH 13/15] fix: [SDK-5581] log a failed startup flag mirror at ERROR Co-authored-by: Cursor --- .../core/internal/features/FeatureManager.kt | 2 +- .../internal/features/FeatureManagerTests.kt | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt index 29cb55f6c0..7cf2f79d57 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt @@ -147,7 +147,7 @@ internal class FeatureManager( try { StartupFlagsCache.write(applicationService.appContext) { configModelStore.model.sdkRemoteFeatureFlags } } catch (t: Throwable) { - Logging.warn("OneSignal: failed to mirror startup flags: ${t.message}", t) + Logging.error("OneSignal: failed to mirror startup flags: ${t.message}", t) } } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt index 39d6c1adf8..792bc781d0 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt @@ -1,6 +1,7 @@ package com.onesignal.core.internal.features import com.onesignal.common.modeling.ModelChangeTags +import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.debug.ILogListener @@ -16,7 +17,10 @@ import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.runs +import io.mockk.unmockkStatic +import io.mockk.verify class FeatureManagerTests : FunSpec({ fun startupSnapshot(vararg flags: FeatureFlag) { @@ -209,4 +213,24 @@ class FeatureManagerTests : FunSpec({ manager.isEnabled(FeatureFlag.SDK_CUSTOM_LOGGING) shouldBe true } + + test("a failed startup flag mirror logs at ERROR") { + val model = mockk() + stubConfigModel(model) + val configModelStore = mockk() + every { configModelStore.model } returns model + every { configModelStore.subscribe(any()) } just runs + val applicationService = mockk() + val failure = IllegalStateException("no context") + every { applicationService.appContext } throws failure + mockkStatic(Logging::class) + every { Logging.error(any(), any()) } just runs + try { + FeatureManager(configModelStore, applicationService).start() + + verify(exactly = 1) { Logging.error("OneSignal: failed to mirror startup flags: no context", failure) } + } finally { + unmockkStatic(Logging::class) + } + } }) From 2ef49fe358f7bc4d86e340314526076795e5ef13 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 22 Oct 2026 13:12:07 -0500 Subject: [PATCH 14/15] fix: [SDK-5581] fall back to the given context when applicationContext is null Co-authored-by: Cursor --- .../core/internal/features/StartupFlagsCache.kt | 3 ++- .../internal/features/StartupFlagsCacheTests.kt | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt index 02742d5131..f4eb64ab06 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt @@ -93,7 +93,8 @@ internal class PrefsStartupFlagsStore( // Device-protected so receivers and jobs that start before first unlock read the real values. // Must hold nothing secret: this storage is readable while the device is still locked. fun storageContext(context: Context): Context { - val app = context.applicationContext + // Null during attachBaseContext and some direct-boot paths. + val app: Context = context.applicationContext ?: context return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) app.createDeviceProtectedStorageContext() else app } } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt index f312c80006..20f0beb93d 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt @@ -149,6 +149,21 @@ class StartupFlagsCacheTests : FunSpec({ flags.isEnabled(logging) shouldBe true } + test("a context with no application context yet still reads device-protected flags") { + val deviceProtected = mockk() + every { deviceProtected.getStringSet(any(), any()) } returns setOf(key) + val dpContext = mockk() + every { dpContext.getSharedPreferences(any(), any()) } returns deviceProtected + val attaching = mockk() + every { attaching.applicationContext } returns null + every { attaching.createDeviceProtectedStorageContext() } returns dpContext + + val flags = StartupFlagsCache.readFile(attaching) + + flags.source shouldBe StartupFlagsSource.STORE + flags.isEnabled(logging) shouldBe true + } + test("an IMMEDIATE flag is off even if its key is in the file") { val immediate = FeatureFlag.SDK_IDENTITY_VERIFICATION startupPrefs().edit().putStringSet("enabled", setOf(immediate.key)).commit() From 364e3a8e647ac1e4ae8b1b27964ee39fce4b832a Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 22 Oct 2026 13:22:39 -0500 Subject: [PATCH 15/15] fix: [SDK-5581] log a missing startup flags context at error Co-authored-by: Cursor --- .../onesignal/core/internal/features/StartupFlagsCache.kt | 2 +- .../core/internal/features/StartupFlagsCacheTests.kt | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt index f4eb64ab06..4ecdc02770 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/StartupFlagsCache.kt @@ -22,7 +22,7 @@ internal object StartupFlagsCache { // The answer is latched for the whole process, so a missing context must be visible in logcat. private fun storeFor(context: Context?): IStartupFlagsStore? { - if (context == null) Log.w(PrefsStartupFlagsStore.TAG, "startup flags: no application context, all off for this process") + if (context == null) Log.e(PrefsStartupFlagsStore.TAG, "startup flags: no application context, all off for this process") return context?.let(::PrefsStartupFlagsStore) } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt index 20f0beb93d..56b2d9943e 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/StartupFlagsCacheTests.kt @@ -3,6 +3,7 @@ package com.onesignal.core.internal.features import android.content.Context import android.content.SharedPreferences import android.os.Build +import android.util.Log import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.common.AppContext @@ -25,6 +26,7 @@ import io.mockk.mockk import io.mockk.verify import org.json.JSONArray import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog import java.util.concurrent.CountDownLatch import java.util.concurrent.atomic.AtomicReference import kotlin.concurrent.thread @@ -253,8 +255,12 @@ class StartupFlagsCacheTests : FunSpec({ test("with no context anywhere the snapshot is off rather than guessed") { StartupFlagsCache.write(appContext()) { listOf(key) } FeatureManager.resetStartupFlagsForTest() + ShadowLog.clear() FeatureManager.startupFlags { null }.source shouldBe StartupFlagsSource.NO_STORE + ShadowLog.getLogsForTag(PrefsStartupFlagsStore.TAG).any { + it.type == Log.ERROR && "no application context" in it.msg + } shouldBe true } test("after a failed commit the next write retries, even with the same flags") {