Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion kmp/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,8 @@ if (!publishSharedKmp) {
'FeatureFlagsClient', 'IFeatureFlagsHttp', 'FeatureManager', 'FeatureFlag',
'DeferredFeatureActivation', 'RemoteFeatureFlagsFetchOutcome',
'RemoteFeatureFlagsResult', 'RemoteFeatureFlagsUnavailableReason',
'FeatureFlagMetadata'].each { publicType ->
'FeatureFlagMetadata', 'StartupFlags', 'StartupFlagsSource', 'StartupFlagsLatch',
'IStartupFlagsStore'].each { publicType ->
if (!headerText.contains("swift_name(\"${publicType}\")")) {
throw new GradleException("Generated Objective-C header does not expose ${publicType}")
}
Expand Down
115 changes: 68 additions & 47 deletions kmp/src/commonMain/kotlin/com/onesignal/features/FeatureManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,17 @@ data class DeferredFeatureActivation(
)

/**
* In-process latch for [FeatureFlag] state.
*
* Hosts pass the latest trusted remote keys (from cache at process start, then
* from [FeatureFlagsClient] after a successful fetch). Persistence, HTTP, and
* lifecycle stay on the platform.
*
* Call [refresh] with [applyAppStartupFlags] `true` once at process start, then
* `false` for later updates so [FeatureActivationMode.APP_STARTUP] flags do not
* flip mid-run. A second `true` is ignored for already-latched APP_STARTUP flags;
* [FeatureManager] tracks that initialization itself. [refresh] returns any
* APP_STARTUP changes that were held.
*
* State updates are guarded by an internal lock so [isEnabled] / [refresh] /
* [enabledFeatureKeys] are safe across host threads.
* Thread-safe per-process latch for [FeatureFlag] state. Call [refreshAtStartup] once with the [StartupFlagsLatch] snapshot, then [refresh] with `false` after fetches.
* `refresh(applyAppStartupFlags = true)` never reads the startup store, so APP_STARTUP flags would not match code that ran before init.
*/
class FeatureManager {
private val lock = PlatformLock()
private var featureStates: Map<FeatureFlag, Boolean> = emptyMap()
private var hasCompletedFirstRefresh = false

// Once set, APP_STARTUP flags latch from here and never from remote keys.
private var startupKeys: Set<String>? = null

fun isEnabled(feature: FeatureFlag): Boolean =
lock.withLock {
featureStates[feature] ?: false
Expand Down Expand Up @@ -69,45 +60,75 @@ class FeatureManager {
remoteKeys: List<String>,
applyAppStartupFlags: Boolean,
localOverrides: List<String>,
): List<DeferredFeatureActivation> = lock.withLock { refreshLocked(remoteKeys, applyAppStartupFlags, localOverrides) }

fun refreshAtStartup(
remoteKeys: List<String>,
startup: StartupFlags,
): List<DeferredFeatureActivation> = refreshAtStartup(remoteKeys, startup, emptyList())

/**
* APP_STARTUP flags latch from [startup] (even after an earlier [refresh]), IMMEDIATE flags from
* [remoteKeys]. Startup flags whose remote value differs are returned as deferred. Only the first call latches.
*/
fun refreshAtStartup(
remoteKeys: List<String>,
startup: StartupFlags,
localOverrides: List<String>,
): List<DeferredFeatureActivation> =
lock.withLock {
val enabledKeys =
(remoteKeys.asSequence() + localOverrides.asSequence())
.map { canonicalizeFeatureFlagId(it) }
.toSet()
val firstSnapshot = startupKeys == null
if (firstSnapshot) startupKeys = startup.keys
refreshLocked(remoteKeys, true, localOverrides, latchFromSnapshot = firstSnapshot)
}

// Callers hold [lock]; PlatformLock is not reentrant on iOS.
private fun refreshLocked(
remoteKeys: List<String>,
applyAppStartupFlags: Boolean,
localOverrides: List<String>,
latchFromSnapshot: Boolean = false,
): List<DeferredFeatureActivation> {
val overrideKeys = localOverrides.mapTo(HashSet()) { canonicalizeFeatureFlagId(it) }
val enabledKeys = remoteKeys.mapTo(HashSet()) { canonicalizeFeatureFlagId(it) } + overrideKeys
val startup = startupKeys

val nextStates = featureStates.toMutableMap()
val deferred = mutableListOf<DeferredFeatureActivation>()
for (feature in FeatureFlag.entries) {
val desired = feature.isEnabledIn(enabledKeys)
when (feature.activationMode) {
FeatureActivationMode.IMMEDIATE -> nextStates[feature] = desired
FeatureActivationMode.APP_STARTUP -> {
// After the first refresh every catalog flag is present in
// [featureStates], including those that resolved to false.
val alreadyLatched = nextStates.containsKey(feature)
val applyStartup =
!alreadyLatched ||
(applyAppStartupFlags && !hasCompletedFirstRefresh)
if (applyStartup) {
nextStates[feature] = desired
} else {
val latched = nextStates[feature] ?: false
if (latched != desired) {
deferred.add(
DeferredFeatureActivation(
key = feature.key,
desiredEnabled = desired,
latchedEnabled = latched,
),
)
}
val nextStates = featureStates.toMutableMap()
val deferred = mutableListOf<DeferredFeatureActivation>()
for (feature in FeatureFlag.entries) {
val desired = feature.isEnabledIn(enabledKeys)
when (feature.activationMode) {
FeatureActivationMode.IMMEDIATE -> nextStates[feature] = desired
FeatureActivationMode.APP_STARTUP -> {
// After the first refresh every catalog flag is present in
// [featureStates], including those that resolved to false.
val alreadyLatched = nextStates.containsKey(feature)
val applyStartup =
latchFromSnapshot || !alreadyLatched ||
(applyAppStartupFlags && !hasCompletedFirstRefresh)
if (applyStartup) {
val latched = if (startup != null) feature.key in startup || feature.key in overrideKeys else desired
nextStates[feature] = latched
if (latched != desired) {
deferred.add(DeferredFeatureActivation(feature.key, desiredEnabled = desired, latchedEnabled = latched))
}
} else {
val latched = nextStates[feature] ?: false
if (latched != desired) {
deferred.add(
DeferredFeatureActivation(
key = feature.key,
desiredEnabled = desired,
latchedEnabled = latched,
),
)
}
}
}
}
featureStates = nextStates
hasCompletedFirstRefresh = true
deferred
}
featureStates = nextStates
hasCompletedFirstRefresh = true
return deferred
}
}
107 changes: 107 additions & 0 deletions kmp/src/commonMain/kotlin/com/onesignal/features/StartupFlags.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.onesignal.features

import kotlin.coroutines.cancellation.CancellationException

/** Where a process's [StartupFlags] came from. Anything but [STORE] means every startup flag is off. */
enum class StartupFlagsSource {
STORE,
MISSING,
UNREADABLE,
NO_STORE,
}

/** The [FeatureActivationMode.APP_STARTUP] flags as one process latched them. */
class StartupFlags(
val source: StartupFlagsSource,
keys: Set<String>,
) {
/** Canonical keys, and always empty unless [source] is [StartupFlagsSource.STORE]. */
val keys: Set<String> =
if (source == StartupFlagsSource.STORE) keys.mapTo(LinkedHashSet()) { canonicalizeFeatureFlagId(it) } else emptySet()

/** False for IMMEDIATE flags: they change mid-run, so a startup copy would go stale. */
fun isEnabled(flag: FeatureFlag): Boolean = flag.activationMode == FeatureActivationMode.APP_STARTUP && flag.key in keys
}

/**
* Platform storage for the startup flags, readable before SDK init without loading the host's main
* persisted state. Implementations must not call back into [StartupFlagsLatch]: its lock is not reentrant.
*/
interface IStartupFlagsStore {
/** Keys from the last [write], or null only when nothing was ever written. Throw when unreadable. */
@Throws(Exception::class)
fun read(): Set<String>?

/** Must be durable when it returns, since the next process reads it at startup. Throw on failure. */
@Throws(Exception::class)
fun write(keys: Set<String>)
}

/** Canonical keys of the APP_STARTUP catalog flags present in [remoteKeys]. */
fun appStartupKeysIn(remoteKeys: Collection<String>): Set<String> {
val canonical = remoteKeys.mapTo(HashSet()) { canonicalizeFeatureFlagId(it) }
return FeatureFlag.entries
.filter { it.activationMode == FeatureActivationMode.APP_STARTUP && it.key in canonical }
.mapTo(LinkedHashSet()) { it.key }
}

/**
* Reads the startup flags once per process and mirrors remote flags back for the next one.
* Keep one instance per process: the first [snapshot] wins, including an unreadable one.
*/
class StartupFlagsLatch {
private val lock = PlatformLock()
private var snapshot: StartupFlags? = null

// Separate from [lock]: a slow durable write must not block a snapshot read, often on main.
private val mirrorLock = PlatformLock()

/** [store] runs only until a snapshot is taken; null means the host has no store yet. */
fun snapshot(store: () -> IStartupFlagsStore?): StartupFlags =
lock.withLock {
snapshot ?: read(store()).also { snapshot = it }
Comment on lines +61 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Consider (3/3): store() and store.read() run under PlatformLock (iOS NSLock is not reentrant). Re-entry from the provider deadlocks on iOS and can be hidden by Android synchronized.

store() is also outside read()’s try/catch, and snapshot has no @Throws. A thrown provider leaves snapshot null, so the next call can return a different value and break “first snapshot wins, including an unreadable one.” Resolve the store / read outside the lock and map provider failure to UNREADABLE.

}

/** Writes the APP_STARTUP subset of [remoteKeys] for the next process. Returns false when unchanged. */
@Throws(Exception::class)
fun mirror(
store: IStartupFlagsStore,
remoteKeys: Collection<String>,
): Boolean = mirror(store) { remoteKeys }

/** [currentKeys] runs under the mirror lock, so racing mirrors always leave the latest keys stored. */
@Throws(Exception::class)
@Suppress("TooGenericExceptionCaught", "SwallowedException")
fun mirror(
store: IStartupFlagsStore,
currentKeys: () -> Collection<String>,
): Boolean =
mirrorLock.withLock {
val wanted = appStartupKeysIn(currentKeys())
// An unreadable store counts as changed: skipping the write would leave it unreadable for good.
val stored =
try {
store.read()
} catch (t: Throwable) {
if (t is CancellationException) throw t
null
}
if (stored == wanted) return@withLock false
store.write(wanted)
true
}

// Errors other than cancellation are cached too: re-reading could give a later caller a different answer.
@Suppress("TooGenericExceptionCaught")
private fun read(store: IStartupFlagsStore?): StartupFlags {
if (store == null) return StartupFlags(StartupFlagsSource.NO_STORE, emptySet())
val stored =
try {
store.read()
} catch (t: Throwable) {
if (t is CancellationException) throw t
return StartupFlags(StartupFlagsSource.UNREADABLE, emptySet())
} ?: return StartupFlags(StartupFlagsSource.MISSING, emptySet())
return StartupFlags(StartupFlagsSource.STORE, appStartupKeysIn(stored))
}
}
Loading
Loading