Skip to content
Merged
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>
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.onesignal.core.internal.operations.impl

import com.onesignal.common.IDManager
import com.onesignal.common.threading.WaiterWithValue
import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.operations.ExecutionResult
Expand All @@ -11,6 +12,7 @@ import com.onesignal.core.internal.startup.IStartableService
import com.onesignal.core.internal.time.ITime
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.user.internal.operations.LoginUserOperation
import com.onesignal.user.internal.operations.impl.states.NewRecordsState
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
Expand All @@ -31,7 +33,7 @@ internal class OperationRepo(
) : IOperationRepo, IStartableService {
internal class OperationQueueItem(
val operation: Operation,
val waiter: WaiterWithValue<Boolean>? = null,
var waiter: WaiterWithValue<Boolean>? = null, // waiter may transfer during operation de-dupe
val bucket: Int,
var retries: Int = 0,
) {
Expand Down Expand Up @@ -161,6 +163,41 @@ internal class OperationRepo(
return
}

// Dedupe LoginUserOperation by onesignalId.
val op = queueItem.operation
if (op is LoginUserOperation) {
val existing =
queue.firstOrNull {
it.operation is LoginUserOperation && it.operation.onesignalId == op.onesignalId
}
Comment thread
nan-li marked this conversation as resolved.
if (existing != null) {
val existingOp = existing.operation as LoginUserOperation
// Preserve the anon-user conversion link if the queued op lacks it (e.g. RecoverFromDroppedLoginBug enqueued with null).
// Skip local ids: merging one would flip canStartExecute to false and strand the op,
// since a local id that never hit the backend will never receive an idTranslation.
val incomingExistingId = op.existingOnesignalId
if (incomingExistingId != null &&
!IDManager.isLocalId(incomingExistingId) &&
existingOp.existingOnesignalId == null
) {
Logging.debug("OperationRepo: internalEnqueue - merging existingOnesignalId=$incomingExistingId into queued LoginUserOperation for onesignalId: ${op.onesignalId}.")
existingOp.existingOnesignalId = incomingExistingId
} else {
Logging.debug("OperationRepo: internalEnqueue - LoginUserOperation for onesignalId: ${op.onesignalId} already exists in the queue.")
}
// Transfer the waiter so enqueueAndWait callers see the queued op's real execution result.
if (queueItem.waiter != null && existing.waiter == null) {
existing.waiter = queueItem.waiter
} else {
queueItem.waiter?.wake(true)
}
Comment thread
nan-li marked this conversation as resolved.
if (!addToStore) {
_operationModelStore.remove(queueItem.operation.id)
}
return
}
}

if (index != null) {
queue.add(index, queueItem)
} else {
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
nan-li marked this conversation as resolved.
Expand Down Expand Up @@ -302,9 +339,15 @@ internal class OperationRepo(
Logging.error("Operation execution failed with eventual retry, pausing the operation repo: $operations")
// keep the failed operation and pause the operation repo from executing
paused = true
// add back all operations to the front of the queue to be re-executed.
// Unblock any enqueueAndWait callers so loginSuspend doesn't hang.
ops.forEach { it.waiter?.wake(false) }
// Re-queue with waiter = null: the operation is preserved for retry
// on next cold start, but the original waiter is detached since it
// was already woken above.
synchronized(queue) {
ops.reversed().forEach { queue.add(0, it) }
ops.reversed().forEach {
queue.add(0, OperationQueueItem(it.operation, waiter = null, bucket = it.bucket, retries = it.retries))
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -378,14 +378,20 @@ internal class OneSignalImp(

if (isBackgroundThreadingEnabled) {
waitForInit(operationName = "login")
suspendifyOnIO { loginHelper.login(externalId, jwtBearerToken) }
} else {
if (!isInitialized) {
throw IllegalStateException("Must call 'initWithContext' before 'login'")
}
}

val context = loginHelper.switchUser(externalId, jwtBearerToken) ?: return

if (isBackgroundThreadingEnabled) {
suspendifyOnIO { loginHelper.enqueueLogin(context) }
} else {
Thread {
runBlocking(runtimeIoDispatcher) {
loginHelper.login(externalId, jwtBearerToken)
loginHelper.enqueueLogin(context)
}
}.start()
}
Comment thread
claude[bot] marked this conversation as resolved.
Expand All @@ -396,14 +402,20 @@ internal class OneSignalImp(

if (isBackgroundThreadingEnabled) {
waitForInit(operationName = "logout")
suspendifyOnIO { logoutHelper.logout() }
} else {
if (!isInitialized) {
throw IllegalStateException("Must call 'initWithContext' before 'logout'")
}
}

val context = logoutHelper.switchUser() ?: return

if (isBackgroundThreadingEnabled) {
suspendifyOnIO { logoutHelper.enqueueLogout(context) }
} else {
Thread {
runBlocking(runtimeIoDispatcher) {
logoutHelper.logout()
logoutHelper.enqueueLogout(context)
}
}.start()
}
Expand Down Expand Up @@ -646,7 +658,8 @@ internal class OneSignalImp(
throw IllegalStateException("'initWithContext failed' before 'login'")
}

loginHelper.login(externalId, jwtBearerToken)
val context = loginHelper.switchUser(externalId, jwtBearerToken) ?: return@withContext
loginHelper.enqueueLogin(context)
}

override suspend fun logoutSuspend() =
Expand All @@ -659,6 +672,7 @@ internal class OneSignalImp(
throw IllegalStateException("'initWithContext failed' before 'logout'")
}

logoutHelper.logout()
val context = logoutHelper.switchUser() ?: return@withContext
logoutHelper.enqueueLogout(context)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,37 +13,55 @@ class LoginHelper(
private val configModel: ConfigModel,
private val lock: Any,
) {
suspend fun login(
internal data class LoginEnqueueContext(
val appId: String,
val newIdentityOneSignalId: String,
val externalId: String,
val existingOneSignalId: String?,
)

/**
* Synchronously switches local user models under the login/logout lock so subsequent
* SDK calls (e.g. addTag) see the new user's identity immediately. Returns context
* needed for [enqueueLogin], or null if the user was already logged in with [externalId]
* (no switch needed).
*/
internal fun switchUser(
externalId: String,
jwtBearerToken: String? = null,
) {
var currentIdentityExternalId: String? = null
var currentIdentityOneSignalId: String? = null
var newIdentityOneSignalId: String = ""

): LoginEnqueueContext? {
synchronized(lock) {
currentIdentityExternalId = identityModelStore.model.externalId
currentIdentityOneSignalId = identityModelStore.model.onesignalId
val currentExternalId = identityModelStore.model.externalId
val currentOneSignalId = identityModelStore.model.onesignalId

if (currentIdentityExternalId == externalId) {
return
if (currentExternalId == externalId) {
return null
}

// TODO: Set JWT Token for all future requests.
userSwitcher.createAndSwitchToNewUser { identityModel, _ ->
identityModel.externalId = externalId
}

newIdentityOneSignalId = identityModelStore.model.onesignalId
val newOneSignalId = identityModelStore.model.onesignalId
val existingOneSignalId =
if (currentExternalId == null) currentOneSignalId else null

return LoginEnqueueContext(configModel.appId, newOneSignalId, externalId, existingOneSignalId)
}
}

/**
* Enqueues the [LoginUserOperation] and suspends until it completes.
*/
internal suspend fun enqueueLogin(context: LoginEnqueueContext) {
val result =
operationRepo.enqueueAndWait(
LoginUserOperation(
configModel.appId,
newIdentityOneSignalId,
externalId,
if (currentIdentityExternalId == null) currentIdentityOneSignalId else null,
context.appId,
context.newIdentityOneSignalId,
context.externalId,
context.existingOneSignalId,
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,43 @@ class LogoutHelper(
private val configModel: ConfigModel,
private val lock: Any,
) {
fun logout() {
internal data class LogoutEnqueueContext(
val appId: String,
val newOnesignalId: String,
)

/**
* Synchronously switches to a new device-scoped user under the login/logout lock
* so subsequent SDK calls (e.g. addTag) see the new anonymous user immediately.
* Returns context needed for [enqueueLogout], or null if no user was logged in
* (no switch needed).
*/
internal fun switchUser(): LogoutEnqueueContext? {
synchronized(lock) {
if (identityModelStore.model.externalId == null) {
return
return null
}

// Create new device-scoped user (clears external ID)
userSwitcher.createAndSwitchToNewUser()

// Enqueue login operation for the new device-scoped user (no external ID)
operationRepo.enqueue(
LoginUserOperation(
configModel.appId,
identityModelStore.model.onesignalId,
null,
// No external ID for device-scoped user
),
)

// TODO: remove JWT Token for all future requests.

return LogoutEnqueueContext(configModel.appId, identityModelStore.model.onesignalId)
}
}

/**
* Enqueues the anonymous [LoginUserOperation] for the newly-created device-scoped user.
*/
internal fun enqueueLogout(context: LogoutEnqueueContext) {
operationRepo.enqueue(
LoginUserOperation(
context.appId,
context.newOnesignalId,
null,
// No external ID for device-scoped user
),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) {
*/
var existingOnesignalId: String?
get() = getOptStringProperty(::existingOnesignalId.name)
private set(value) {
internal set(value) { // `internal` so OperationRepo can merge during de-dupe
setOptStringProperty(::existingOnesignalId.name, value)
}

Expand Down
Loading
Loading