Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
347b359
fix(ci): diff coverage uses PR base SHA and aggregate gate
Apr 9, 2026
7693825
SDK-4363: Turbine remote SDK feature flags and foreground refresh
Apr 14, 2026
72890cc
Merge branch 'main' into sdk-4363-android-sdk-turbine-feature-flags
Apr 14, 2026
c63f686
feature flag PR tweaks
Apr 14, 2026
3720ec5
fix(core): address PR 2612 review for FeatureFlagsRefreshService
Apr 14, 2026
64a1068
fix(core): PR 2612 follow-ups — fetch outcome, cancellation, docs, ke…
Apr 20, 2026
c7a2720
more logging
Apr 21, 2026
8f47b9c
chore(core): drop revert-before-merge TODOs on kept diagnostics
Apr 21, 2026
ce19184
Addressed comments
Apr 23, 2026
0a3d18e
removing features node in ConfigModel
Apr 23, 2026
68f2df3
nit(style): fix spotless formatting and core detekt issues
nan-li Apr 24, 2026
2861144
magic numbers
Apr 27, 2026
8786852
fix(core): preserve feature flag cache against snapshot race and cont…
Apr 27, 2026
98e436d
fix(core): dedup remote feature-flags fetch on same-appId hydrate
Apr 27, 2026
74936f4
fix: login race causes subsequent calls to target previous user
nan-li Apr 18, 2026
ef813b1
Preserve existingOnesignalId when deduping LoginUserOperation
nan-li Apr 20, 2026
dbe39d4
Transfer waiter on dedupe and add merge-path test coverage
nan-li Apr 20, 2026
af780de
Fix FAIL_PAUSE_OPREPO hanging enqueueAndWait callers
nan-li Apr 20, 2026
90bdb72
tests: Use waitForInternalEnqueue helper in dedupe test
nan-li Apr 22, 2026
6b87e29
fix: logout race causes subsequent calls to target previous user
nan-li Apr 22, 2026
9d6341a
nit: update some comments for clarity
nan-li Apr 22, 2026
e298b58
fix: skip dedup merge of local existingOnesignalId
nan-li Apr 22, 2026
dce3184
tests: cover OperationRepo dedupe when queued op already has a waiter
nan-li Apr 27, 2026
ea96c18
nit: detekt
nan-li Apr 27, 2026
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
18 changes: 10 additions & 8 deletions OneSignalSDK/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ buildscript {
kotlinVersion = '1.9.25'
dokkaVersion = '1.9.10'
coroutinesVersion = '1.7.3'
kotlinxSerializationJsonVersion = '1.6.3'
kotestVersion = '5.8.0'
ioMockVersion = '1.13.2'
// AndroidX Lifecycle and Activity versions
Expand All @@ -38,14 +39,15 @@ buildscript {
maven { url 'https://developer.huawei.com/repo/' }
}
sharedDeps = [
"com.android.tools.build:gradle:$androidGradlePluginVersion",
"com.google.gms:google-services:$googleServicesGradlePluginVersion",
"com.huawei.agconnect:agcp:$huaweiAgconnectVersion",
"org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion",
"org.jetbrains.dokka:dokka-gradle-plugin:$dokkaVersion",
"io.gitlab.arturbosch.detekt:detekt-gradle-plugin:$detektVersion",
"com.diffplug.spotless:spotless-plugin-gradle:$spotlessVersion",
"com.vanniktech.maven.publish:com.vanniktech.maven.publish.gradle.plugin:0.32.0"
"com.android.tools.build:gradle:$androidGradlePluginVersion",
"com.google.gms:google-services:$googleServicesGradlePluginVersion",
"com.huawei.agconnect:agcp:$huaweiAgconnectVersion",
"org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion",
"org.jetbrains.kotlin:kotlin-serialization:$kotlinVersion",
"org.jetbrains.dokka:dokka-gradle-plugin:$dokkaVersion",
"io.gitlab.arturbosch.detekt:detekt-gradle-plugin:$detektVersion",
"com.diffplug.spotless:spotless-plugin-gradle:$spotlessVersion",
"com.vanniktech.maven.publish:com.vanniktech.maven.publish.gradle.plugin:0.32.0"
]
}

Expand Down
4 changes: 4 additions & 0 deletions OneSignalSDK/detekt/detekt-baseline-core.xml
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@
<ID>LongMethod:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun createUser( createUserOperation: LoginUserOperation, operations: List&lt;Operation>, ): ExecutionResponse</ID>
<ID>LongMethod:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun loginUser( loginUserOp: LoginUserOperation, operations: List&lt;Operation>, ): ExecutionResponse</ID>
<ID>LongMethod:OperationRepo.kt$OperationRepo$internal suspend fun executeOperations(ops: List&lt;OperationQueueItem>)</ID>
<ID>LongMethod:OperationRepo.kt$OperationRepo$private fun internalEnqueue( queueItem: OperationQueueItem, flush: Boolean, addToStore: Boolean, index: Int? = null, )</ID>
<ID>LongMethod:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendAndCreateOutcomeEvent( name: String, weight: Float, // Note: this is optional sessionTime: Long, influences: List&lt;Influence>, ): OutcomeEvent?</ID>
<ID>LongMethod:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendUniqueOutcomeEvent( name: String, sessionInfluences: List&lt;Influence>, ): OutcomeEvent?</ID>
<ID>LongMethod:OutcomeEventsRepository.kt$OutcomeEventsRepository$override suspend fun getAllEventsToSend(): List&lt;OutcomeEventParams></ID>
Expand Down Expand Up @@ -609,6 +610,9 @@
<ID>UnusedPrivateMember:OperationRepo.kt$OperationRepo$private val _time: ITime</ID>
<ID>UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("'initWithContext failed' before 'login'")</ID>
<ID>UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("'initWithContext failed' before 'logout'")</ID>
<ID>UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before 'login'")</ID>
<ID>UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before 'logout'")</ID>
<ID>UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before use")</ID>
<ID>UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw initFailureException ?: IllegalStateException("Initialization failed. Cannot proceed.")</ID>
</CurrentIssues>
</SmellBaseline>
2 changes: 2 additions & 0 deletions OneSignalSDK/onesignal/core/build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'org.jetbrains.kotlin.plugin.serialization'
id 'com.diffplug.spotless'
id 'com.vanniktech.maven.publish'
id 'io.gitlab.arturbosch.detekt'
Expand Down Expand Up @@ -73,6 +74,7 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationJsonVersion"

// AndroidX Lifecycle and Activity
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import com.onesignal.common.modules.IModule
import com.onesignal.common.services.ServiceBuilder
import com.onesignal.core.internal.application.IApplicationService
import com.onesignal.core.internal.application.impl.ApplicationService
import com.onesignal.core.internal.backend.IFeatureFlagsBackendService
import com.onesignal.core.internal.backend.IParamsBackendService
import com.onesignal.core.internal.backend.impl.FeatureFlagsBackendService
import com.onesignal.core.internal.backend.impl.ParamsBackendService
import com.onesignal.core.internal.background.IBackgroundManager
import com.onesignal.core.internal.background.impl.BackgroundManager
import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.config.impl.ConfigModelStoreListener
import com.onesignal.core.internal.config.impl.FeatureFlagsRefreshService
import com.onesignal.core.internal.database.IDatabaseProvider
import com.onesignal.core.internal.database.impl.DatabaseProvider
import com.onesignal.core.internal.device.IDeviceService
Expand Down Expand Up @@ -61,7 +64,9 @@ internal class CoreModule : IModule {
builder.register<ConfigModelStore>().provides<ConfigModelStore>()
builder.register<FeatureManager>().provides<IFeatureManager>()
builder.register<ParamsBackendService>().provides<IParamsBackendService>()
builder.register<FeatureFlagsBackendService>().provides<IFeatureFlagsBackendService>()
builder.register<ConfigModelStoreListener>().provides<IStartableService>()
builder.register<FeatureFlagsRefreshService>().provides<IStartableService>()

// Operations
builder.register<OperationModelStore>().provides<OperationModelStore>()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.onesignal.core.internal.backend

import kotlinx.serialization.json.JsonObject

/**
* Result of the dedicated SDK feature-flags endpoint (separate from [IParamsBackendService]).
*
* @param enabledKeys Feature keys that should be treated as enabled for this device/SDK.
* @param metadata Optional per-flag payload (e.g. weights), keyed by flag id. Parsed from sibling
* keys in the response JSON (see [com.onesignal.core.internal.backend.impl.FeatureFlagsJsonParser]).
*/
internal data class RemoteFeatureFlagsResult(
val enabledKeys: List<String>,
val metadata: JsonObject?,
) {
companion object {
val EMPTY = RemoteFeatureFlagsResult(emptyList(), null)
}
}

/**
* Outcome of [IFeatureFlagsBackendService.fetchRemoteFeatureFlags].
* [Unavailable] means the client did not get a trustworthy response (HTTP error, invalid body, etc.);
* callers should keep previously cached flags. [Success] includes a valid HTTP parse, including an
* empty `features` array from the server.
*/
internal sealed class RemoteFeatureFlagsFetchOutcome {
data class Success(val result: RemoteFeatureFlagsResult) : RemoteFeatureFlagsFetchOutcome()

data object Unavailable : RemoteFeatureFlagsFetchOutcome()
}

/**
* Fetches remote feature flags for the current app via **GET**
* `apps/{app_id}/sdk/features/{platform}/{sdk_version}` (Turbine; see
* [com.onesignal.core.internal.backend.impl.FeatureFlagsBackendService]).
*/
internal interface IFeatureFlagsBackendService {
suspend fun fetchRemoteFeatureFlags(appId: String): RemoteFeatureFlagsFetchOutcome
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ internal class ParamsObject(
var locationShared: Boolean? = null,
var requiresUserPrivacyConsent: Boolean? = null,
var opRepoExecutionInterval: Long? = null,
val features: List<String> = emptyList(),
var influenceParams: InfluenceParamsObject,
var fcmParams: FCMParamsObject,
val remoteLoggingParams: RemoteLoggingParamsObject,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package com.onesignal.core.internal.backend.impl

import com.onesignal.common.OneSignalUtils
import com.onesignal.core.internal.backend.IFeatureFlagsBackendService
import com.onesignal.core.internal.backend.RemoteFeatureFlagsFetchOutcome
import com.onesignal.core.internal.http.IHttpClient
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging

/**
* Turbine SDK feature flags endpoint ([OneSignal/turbine#1681](https://github.com/OneSignal/turbine/pull/1681)).
*
* HTTP, logging, and [OneSignalUtils] are platform-specific; path shape and validation live in
* [TurbineSdkFeatureFlagsPath], and JSON parsing in [FeatureFlagsJsonParser] (both KMP-friendly).
*
* **GET** `apps/{app_id}/sdk/features/{platform}/{sdk_version}` relative to
* [com.onesignal.core.internal.config.ConfigModel.apiUrl] (app-provided base URL).
*
* - **platform** is always **`android`** for this SDK client.
* - **sdk_version** is [OneSignalUtils.sdkVersion] (same label as the `SDK-Version` header segment), e.g.
* `050801` or `050801-beta`; see [isValidFeaturesSdkVersionLabel].
*
* Response: `{ "features": [ "flag_key", ... ] }`.
*/
internal class FeatureFlagsBackendService(
private val http: IHttpClient,
) : IFeatureFlagsBackendService {
override suspend fun fetchRemoteFeatureFlags(appId: String): RemoteFeatureFlagsFetchOutcome {
Logging.log(LogLevel.DEBUG, "FeatureFlagsBackendService.fetchRemoteFeatureFlags(appId=$appId)")

val sdkVersion = OneSignalUtils.sdkVersion
if (!isValidFeaturesSdkVersionLabel(sdkVersion)) {
Logging.warn(
"FeatureFlagsBackendService: sdk version not usable for Turbine path (expected " +
"6-digit label optional -suffix, e.g. 050801 or 050801-beta): '$sdkVersion'",
)
return RemoteFeatureFlagsFetchOutcome.Unavailable
}

val path =
TurbineSdkFeatureFlagsPath.buildGetPath(
appId = appId,
platform = TURBINE_FEATURES_PLATFORM_ANDROID,
sdkVersion = sdkVersion,
)

val response = http.get(path, null)
val body = response.payload
if (!response.isSuccess) {
val msg =
"FeatureFlagsBackendService: non-success status=${response.statusCode} body=${bodySnippet(body)}"
// 4xx is likely a permanent misconfiguration (e.g. 403 Forbidden when the app is not
// enrolled for Turbine feature flags) and worth surfacing at WARN; other failures are
// typically transient (network blip, 5xx) and stay at DEBUG to avoid log spam.
if (response.isClientError) Logging.warn(msg) else Logging.debug(msg)
return RemoteFeatureFlagsFetchOutcome.Unavailable
}
if (body.isNullOrBlank()) {
Logging.warn(
"FeatureFlagsBackendService: empty body for success status=${response.statusCode}",
)
return RemoteFeatureFlagsFetchOutcome.Unavailable
}

val parsed = FeatureFlagsJsonParser.parseSuccessful(body)
return if (parsed != null) {
RemoteFeatureFlagsFetchOutcome.Success(parsed)
} else {
Logging.warn(
"FeatureFlagsBackendService: response body is not valid Turbine feature-flags JSON: " +
bodySnippet(body),
)
RemoteFeatureFlagsFetchOutcome.Unavailable
}
}

/**
* Trim [body] to a short, single-line snippet safe for logcat. Caps at
* [LOG_BODY_SNIPPET_MAX_CHARS] so we never dump large payloads into logs.
*/
private fun bodySnippet(body: String?): String {
if (body.isNullOrEmpty()) return "<empty>"
val flattened = body.replace('\n', ' ').replace('\r', ' ')
return if (flattened.length <= LOG_BODY_SNIPPET_MAX_CHARS) {
flattened
} else {
flattened.take(LOG_BODY_SNIPPET_MAX_CHARS) + "…"
}
}

companion object {
/**
* Turbine `:platform` segment for the OneSignal Android SDK (this client).
*/
const val TURBINE_FEATURES_PLATFORM_ANDROID = "android"

/**
* Max chars of an HTTP response body included in diagnostic logs. Turbine error bodies
* (e.g. `{"errors":["Forbidden"]}`) are tiny, so 200 chars is plenty and bounds worst-case
* log size if an unexpected payload is returned.
*/
private const val LOG_BODY_SNIPPET_MAX_CHARS = 200

/**
* Returns true when [label] is safe to send as the Turbine `:sdk_version` path segment.
* @see TurbineSdkFeatureFlagsPath.isValidFeaturesSdkVersionLabel
*/
fun isValidFeaturesSdkVersionLabel(label: String): Boolean = TurbineSdkFeatureFlagsPath.isValidFeaturesSdkVersionLabel(label)

/**
* Path only (relative to API base), matching `/apps/:app_id/sdk/features/:platform/:sdk_version`.
* @see TurbineSdkFeatureFlagsPath.buildGetPath
*/
internal fun buildFeatureFlagsGetPath(
appId: String,
platform: String,
sdkVersion: String,
): String = TurbineSdkFeatureFlagsPath.buildGetPath(appId, platform, sdkVersion)
}
}
Loading
Loading