From 1ad6f365f066f366b13f0dbef71cc2cd80faddb2 Mon Sep 17 00:00:00 2001 From: abdulraqeeb33 Date: Tue, 6 Oct 2026 14:27:08 -0500 Subject: [PATCH 01/20] fix: [SDK-5305] prompt on the host activity when PermissionsActivity is missing (#2759) Co-authored-by: AR Abdul Azeez Co-authored-by: Cursor --- .../onesignal/common/IHostPermissionPrompt.kt | 22 + .../com/onesignal/common/OneSignalWrapper.kt | 8 + .../common/threading/InFlightResult.kt | 51 ++ .../permissions/PermissionsViewModel.kt | 161 +------ .../internal/permissions/impl/HostPrompt.kt | 139 ++++++ .../impl/PermissionsResultHandler.kt | 132 ++++++ .../impl/RequestPermissionService.kt | 187 +++++++- .../common/threading/InFlightResultTests.kt | 63 +++ .../permissions/PermissionsViewModelTests.kt | 2 + .../RequestPermissionServiceTests.kt | 440 ++++++++++++++++++ .../LocationPermissionController.kt | 23 +- .../LocationPermissionControllerTests.kt | 37 ++ .../impl/NotificationPermissionController.kt | 40 +- 13 files changed, 1110 insertions(+), 195 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/IHostPermissionPrompt.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/InFlightResult.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/impl/HostPrompt.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/impl/PermissionsResultHandler.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/InFlightResultTests.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/RequestPermissionServiceTests.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/IHostPermissionPrompt.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/IHostPermissionPrompt.kt new file mode 100644 index 0000000000..44c25134e0 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/IHostPermissionPrompt.kt @@ -0,0 +1,22 @@ +package com.onesignal.common + +/** + * Shows the Android permission dialog on the host's own activity, for wrappers whose + * activity is not a `ComponentActivity` and so cannot host an activity result launcher. + */ +interface IHostPermissionPrompt { + /** Receives the user's answer. May be called from any thread. */ + fun interface Callback { + /** Call once after [request] returns true, and do not call it when [request] returns false. */ + fun onResult(granted: Boolean) + } + + /** + * Return false, and do not call [Callback], when no activity can prompt. + * Otherwise call [Callback.onResult] once, even if the host goes away first. + */ + fun request( + androidPermission: String, + callback: Callback, + ): Boolean +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/OneSignalWrapper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/OneSignalWrapper.kt index be119ab5b9..80e603fa7c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/OneSignalWrapper.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/OneSignalWrapper.kt @@ -12,4 +12,12 @@ object OneSignalWrapper { */ @JvmStatic var sdkVersion: String? = null + + /** + * Set on activity attach and cleared on detach by wrappers whose host activity cannot + * host an activity result launcher. Consulted only when `PermissionsActivity` is missing. + */ + @JvmStatic + @Volatile + var hostPermissionPrompt: IHostPermissionPrompt? = null } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/InFlightResult.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/InFlightResult.kt new file mode 100644 index 0000000000..f644d8c38f --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/InFlightResult.kt @@ -0,0 +1,51 @@ +package com.onesignal.common.threading + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** + * One caller runs [block]. Later callers wait for that result instead of running it again. + */ +class InFlightResult { + private val gate = Mutex() + private var current: CompletableDeferred? = null + + /** One caller runs [block]. Later callers wait for that result. */ + suspend fun share( + fallback: T, + block: suspend () -> T, + ): T { + val (deferred, owner) = + gate.withLock { + val existing = current + if (existing != null) { + existing to false + } else { + CompletableDeferred().also { current = it } to true + } + } + if (!owner) { + return deferred.await() + } + return try { + val result = block() + deferred.complete(result) + result + } finally { + // A cancelled owner cannot wait on a held lock, which would leave [current] stuck. + withContext(NonCancellable) { + if (!deferred.isCompleted) { + deferred.complete(fallback) + } + gate.withLock { + if (current === deferred) { + current = null + } + } + } + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt index 1ac62a5553..3814007d95 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt @@ -5,32 +5,20 @@ import android.content.pm.PackageManager import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.onesignal.OneSignal +import com.onesignal.core.internal.permissions.impl.PermissionPromptRequest +import com.onesignal.core.internal.permissions.impl.PermissionsResultHandler import com.onesignal.core.internal.permissions.impl.RequestPermissionService -import com.onesignal.core.internal.preferences.IPreferencesService -import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys -import com.onesignal.core.internal.preferences.PreferenceStores -import com.onesignal.debug.internal.logging.Logging import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -/** - * ViewModel that handles the business logic for permission requests. - * This separates the permission handling logic from the Activity lifecycle. - * Uses AndroidX ViewModel with StateFlow for lifecycle-aware state management. - * - * Responsibilities: - * - Store permission request state (survives configuration changes) - * - Handle permission result business logic - * - Manage callbacks and preferences - * - Does NOT hold Activity references or call Activity APIs directly - */ +/** Permission state for PermissionsActivity. Survives rotation and does not hold the Activity. */ class PermissionsViewModel : ViewModel() { - // Lazy initialization to ensure OneSignal is ready before accessing services + // OneSignal.getService throws before init. private val requestPermissionService: RequestPermissionService by lazy { OneSignal.getService() } - private val preferenceService: IPreferencesService by lazy { OneSignal.getService() } + private val resultHandler: PermissionsResultHandler by lazy { requestPermissionService.resultHandler } private val _shouldFinish = MutableStateFlow(false) val shouldFinish: StateFlow = _shouldFinish.asStateFlow() @@ -43,11 +31,7 @@ class PermissionsViewModel : ViewModel() { private var androidPermissionString: String? = null - /** - * Initialize OneSignal and the ViewModel with intent data. - * Returns false if initialization fails. - * @param activity Activity context (not stored, used only for initialization) - */ + /** Returns false when init or the intent extras fail. Does not retain [activity]. */ suspend fun initialize( activity: Activity, permissionType: String?, @@ -82,11 +66,7 @@ class PermissionsViewModel : ViewModel() { return true } - /** - * Reset the waiting flag. This should be called when the activity is interrupted - * or destroyed without completing the permission request flow. - * This ensures the permission dialog can be shown again. - */ + /** Clears waiting so an interrupted activity can prompt again. */ fun resetWaitingState() { _waiting.value = false } @@ -99,12 +79,7 @@ class PermissionsViewModel : ViewModel() { requestPermissionService.shouldShowRequestPermissionRationaleBeforeRequest = shouldShowRationale } - /** - * Handle the permission request result. - * Activity should call this with the result from onRequestPermissionsResult. - * - * @param shouldShowRationaleAfter The result of shouldShowRequestPermissionRationale AFTER the user responded - */ + /** [shouldShowRationaleAfter] is the reading after the user answered. */ fun onRequestPermissionsResult( permissions: Array, grantResults: IntArray, @@ -116,120 +91,24 @@ class PermissionsViewModel : ViewModel() { viewModelScope.launch { delay(DELAY_TIME_CALLBACK_CALL.toLong()) - val granted: Boolean - val showSettings: Boolean - - if (permissions.isEmpty()) { - granted = false - showSettings = false - } else { - val permission = permissions[0] - granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED - - if (granted) { - preferenceService.saveBool( - PreferenceStores.ONESIGNAL, - "${PreferenceOneSignalKeys.PREFS_OS_USER_RESOLVED_PERMISSION_PREFIX}$permission", - true, - ) - showSettings = false - } else { - showSettings = shouldShowSettings(permission, shouldShowRationaleAfter) - } - - // Record that OneSignal has now requested this permission at least once. This - // must be persisted after shouldShowSettings() reads it so the recovery path - // only considers requests prior to the current one. - preferenceService.saveBool( - PreferenceStores.ONESIGNAL, - "${PreferenceOneSignalKeys.PREFS_OS_PROMPTED_PERMISSION_PREFIX}$permission", - true, - ) - } - - // Execute the callback - executeCallback(granted, showSettings) + val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED - // Signal the activity to finish - _shouldFinish.value = true - } - } + resultHandler.handleResult( + PermissionPromptRequest( + permissionRequestType, + permissions.firstOrNull(), + requestPermissionService.fallbackToSettings, + requestPermissionService.shouldShowRequestPermissionRationaleBeforeRequest, + ), + granted, + shouldShowRationaleAfter, + ) - private fun executeCallback( - granted: Boolean, - showSettings: Boolean, - ) { - permissionRequestType?.let { type -> - val callback = - requestPermissionService.getCallback(type) - ?: throw RuntimeException("Missing handler for permissionRequestType: $type") - - if (granted) { - callback.onAccept() - } else { - callback.onReject(showSettings) - } - } ?: run { - // There is a small chance ViewModel was never fully initialized (e.g. process death or OneSignal init hanging while prompting). - // We can't safely resolve a callback in this state, so just finish the flow. - Logging.error("PermissionsViewModel: Cannot resolve callback because permissionRequestType is null. Ending permission flow.") + // Signal the activity to finish _shouldFinish.value = true } } - /** - * Determine if we should show the settings fallback. - * This matches the original logic from the Activity. - * - * We want to show settings after the user has clicked "Don't Allow" 2 times. - * After the first time shouldShowRequestPermissionRationale becomes true, after - * the second time shouldShowRequestPermissionRationale becomes false again. We - * look for the change from `true` -> `false`. When this happens we remember this - * rejection, as the user will never be prompted again. - * - * @param permission The permission string - * @param shouldShowRationaleAfter The result of shouldShowRequestPermissionRationale AFTER the user responded - */ - private fun shouldShowSettings( - permission: String, - shouldShowRationaleAfter: Boolean, - ): Boolean { - if (!requestPermissionService.fallbackToSettings) { - return false - } - - val resolvedKey = "${PreferenceOneSignalKeys.PREFS_OS_USER_RESOLVED_PERMISSION_PREFIX}$permission" - val rationaleBefore = requestPermissionService.shouldShowRequestPermissionRationaleBeforeRequest - - // We want to show settings after the user has clicked "Don't Allow" 2 times. - // After the first time shouldShowRequestPermissionRationale becomes true, after - // the second time shouldShowRequestPermissionRationale becomes false again. We - // look for the change from `true` -> `false`. When this happens we remember this - // rejection, as the user will never be prompted again. - if (rationaleBefore && !shouldShowRationaleAfter) { - preferenceService.saveBool(PreferenceStores.ONESIGNAL, resolvedKey, true) - return false - } - - // Recovery path for an already permanently-denied permission. If the OS won't surface - // its prompt (rationale is false before and after a denied request) but OneSignal has - // requested this permission before, the permission is permanently blocked even though - // we never witnessed the true -> false transition (e.g. it was denied across a prior - // session or outside OneSignal's flow). Remember it so the fallback is no longer stuck. - val hasPromptedBefore = - preferenceService.getBool( - PreferenceStores.ONESIGNAL, - "${PreferenceOneSignalKeys.PREFS_OS_PROMPTED_PERMISSION_PREFIX}$permission", - false, - ) ?: false - if (hasPromptedBefore && !rationaleBefore && !shouldShowRationaleAfter) { - preferenceService.saveBool(PreferenceStores.ONESIGNAL, resolvedKey, true) - return true - } - - return preferenceService.getBool(PreferenceStores.ONESIGNAL, resolvedKey, false) ?: false - } - override fun onCleared() { super.onCleared() // Clean up any resources if needed diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/impl/HostPrompt.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/impl/HostPrompt.kt new file mode 100644 index 0000000000..c977cfaf9d --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/impl/HostPrompt.kt @@ -0,0 +1,139 @@ +package com.onesignal.core.internal.permissions.impl + +import android.app.Activity +import android.os.Handler +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.app.ActivityCompat +import com.onesignal.common.AndroidUtils +import com.onesignal.common.IHostPermissionPrompt +import com.onesignal.debug.internal.logging.Logging + +internal fun runOnMainThread(block: () -> Unit) { + if (AndroidUtils.isRunningOnMainThread()) { + block() + } else { + Handler(Looper.getMainLooper()).post(block) + } +} + +/** + * Prompts on the host app's own activity, used when `PermissionsActivity` was dropped from the + * merged manifest. Prefers a wrapper-supplied prompt, otherwise the activity's result registry. + */ +internal class HostPrompt( + private val permission: String, + private val wrapper: IHostPermissionPrompt?, + private val currentActivity: () -> Activity?, + private val isComplete: () -> Boolean, + private val onResult: (granted: Boolean, rationaleAfter: Boolean?) -> Unit, +) { + private var boundActivity: Activity? = null + private var boundRegistry: ActivityResultRegistry? = null + private var launcher: ActivityResultLauncher? = null + + /** True only after the registry, not a wrapper, accepted this prompt. */ + private var registryAccepted = false + + var rationaleBefore = false + private set + + /** @return false when no surface could be asked, so the caller can complete as denied. */ + @Suppress("ReturnCount") + fun start(activity: Activity): Boolean { + rationaleBefore = rationaleOn(activity) ?: false + + if (wrapper != null) { + val accepted = wrapper.request(permission) { granted -> runOnMainThread { deliver(granted) } } + if (isComplete()) { + return true + } + if (accepted) { + Logging.debug("Prompting for $permission through the wrapper host.") + return true + } + Logging.warn("Wrapper host declined to prompt for $permission.") + } + + if (activity !is ComponentActivity) { + return false + } + + registryAccepted = true + bindTo(activity) + launcher?.launch(permission) + Logging.debug("Prompting for $permission on the host activity result registry.") + return true + } + + /** + * Re-register on a recreated host. A second launch while that request is in flight + * comes back as an empty denial and drops the real answer. + */ + @Suppress("ReturnCount") + fun rebind(activity: Activity) { + if (isComplete() || activity === boundActivity) { + return + } + // A wrapper that accepted the prompt redelivers across its own activity changes. + if (!registryAccepted || activity !is ComponentActivity) { + return + } + + // Config change: the restored registry already has this request. A second launch comes back denied. + val configChange = boundActivity?.isChangingConfigurations == true + Logging.debug("Host activity changed while prompting for $permission. Rebinding.") + if (activity.activityResultRegistry !== boundRegistry) { + launcher?.unregister() + } + bindTo(activity) + if (configChange) { + return + } + launcher?.launch(permission) + } + + private fun bindTo(activity: ComponentActivity) { + boundActivity = activity + boundRegistry = activity.activityResultRegistry + // The no-LifecycleOwner overload is required: OneSignal routinely reaches this point + // after the host activity is already STARTED, which the lifecycle-aware overload rejects. + val registered = + activity.activityResultRegistry.register( + "$REGISTRY_KEY_PREFIX$permission", + ActivityResultContracts.RequestPermission(), + ) { granted -> deliver(granted) } + + // register() hands back an answer parked by an earlier process before it returns. Leaving + // the launcher null then keeps callers from prompting again for a question already answered. + if (isComplete()) { + registered.unregister() + launcher = null + } else { + launcher = registered + } + } + + private fun deliver(granted: Boolean) { + launcher?.unregister() + launcher = null + Logging.debug("Host prompt for $permission returned granted=$granted.") + onResult(granted, rationaleOn(currentActivity() ?: boundActivity)) + } + + /** @return null when no activity could be read, which is not the same as a false reading. */ + private fun rationaleOn(activity: Activity?): Boolean? { + if (activity == null) { + return null + } + return runCatching { ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) } + .getOrNull() + } + + private companion object { + const val REGISTRY_KEY_PREFIX = "com.onesignal.core.permissions.HOST_PROMPT#" + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/impl/PermissionsResultHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/impl/PermissionsResultHandler.kt new file mode 100644 index 0000000000..42f26797fa --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/impl/PermissionsResultHandler.kt @@ -0,0 +1,132 @@ +package com.onesignal.core.internal.permissions.impl + +import com.onesignal.core.internal.preferences.IPreferencesService +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.debug.internal.logging.Logging + +/** + * Per request, so overlapping prompts stay independent. A null permission is a denial. + */ +internal data class PermissionPromptRequest( + val permissionRequestType: String?, + val permission: String?, + val fallbackToSettings: Boolean, + val rationaleBefore: Boolean, +) + +/** + * Turns a permission grant into the settings-fallback decision and the registered callback. + * Shared by `PermissionsActivity`, the host result registry, and wrapper-supplied prompts. + */ +@Suppress("ConstructorParameterNaming") +internal class PermissionsResultHandler( + private val _requestPermissionService: RequestPermissionService, + private val _preferences: IPreferencesService, +) { + /** + * @param rationaleAfter null when no activity was available to read it from. Unknown is not + * the same as false: a false reading is what marks a permission permanently denied. + */ + fun handleResult( + request: PermissionPromptRequest, + granted: Boolean, + rationaleAfter: Boolean?, + dialogShown: Boolean = true, + ) { + val permission = request.permission + var isGranted = granted + var showSettings = false + + if (permission == null) { + isGranted = false + } else { + if (isGranted) { + _preferences.saveBool( + PreferenceStores.ONESIGNAL, + "${PreferenceOneSignalKeys.PREFS_OS_USER_RESOLVED_PERMISSION_PREFIX}$permission", + true, + ) + } else { + showSettings = shouldShowSettings(request, permission, rationaleAfter) + } + + // After shouldShowSettings reads it, so this attempt is not "prompted before". + // A missing dialog must not count: the recovery path treats the flag as an OS refusal. + if (dialogShown) { + _preferences.saveBool( + PreferenceStores.ONESIGNAL, + "${PreferenceOneSignalKeys.PREFS_OS_PROMPTED_PERMISSION_PREFIX}$permission", + true, + ) + } + } + + executeCallback(request.permissionRequestType, isGranted, showSettings) + } + + @Suppress("TooGenericExceptionThrown") + private fun executeCallback( + permissionRequestType: String?, + granted: Boolean, + showSettings: Boolean, + ) { + permissionRequestType?.let { type -> + val callback = + _requestPermissionService.getCallback(type) + ?: throw RuntimeException("Missing handler for permissionRequestType: $type") + + if (granted) { + callback.onAccept() + } else { + callback.onReject(showSettings) + } + } ?: Logging.error( + "PermissionsResultHandler: Cannot resolve callback because permissionRequestType is null. Ending permission flow.", + ) + } + + /** + * Settings is offered once the OS stops showing its prompt. That shows up as + * `shouldShowRequestPermissionRationale` going true -> false across a denied request. + */ + @Suppress("ReturnCount") + private fun shouldShowSettings( + request: PermissionPromptRequest, + permission: String, + rationaleAfter: Boolean?, + ): Boolean { + if (!request.fallbackToSettings) { + return false + } + + val resolvedKey = "${PreferenceOneSignalKeys.PREFS_OS_USER_RESOLVED_PERMISSION_PREFIX}$permission" + val alreadyResolved = _preferences.getBool(PreferenceStores.ONESIGNAL, resolvedKey, false) ?: false + + // No reading means no transition to infer, so report only what was already known. + if (rationaleAfter == null) { + return alreadyResolved + } + + if (request.rationaleBefore && !rationaleAfter) { + _preferences.saveBool(PreferenceStores.ONESIGNAL, resolvedKey, true) + return false + } + + // Recovery path for an already permanently-denied permission: rationale false before and + // after a denied request means the OS will not prompt again, even though we never saw the + // true -> false transition. + val hasPromptedBefore = + _preferences.getBool( + PreferenceStores.ONESIGNAL, + "${PreferenceOneSignalKeys.PREFS_OS_PROMPTED_PERMISSION_PREFIX}$permission", + false, + ) ?: false + if (hasPromptedBefore && !request.rationaleBefore && !rationaleAfter) { + _preferences.saveBool(PreferenceStores.ONESIGNAL, resolvedKey, true) + return true + } + + return alreadyResolved + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/impl/RequestPermissionService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/impl/RequestPermissionService.kt index 25a323682d..1cf30f73ab 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/impl/RequestPermissionService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/impl/RequestPermissionService.kt @@ -1,21 +1,33 @@ package com.onesignal.core.internal.permissions.impl import android.app.Activity +import android.content.ActivityNotFoundException import android.content.Intent +import androidx.activity.ComponentActivity +import com.onesignal.common.OneSignalWrapper import com.onesignal.core.R import com.onesignal.core.activities.PermissionsActivity import com.onesignal.core.internal.application.IActivityLifecycleHandler import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.permissions.IRequestPermissionService import com.onesignal.core.internal.permissions.PermissionsViewModel +import com.onesignal.core.internal.preferences.IPreferencesService +import com.onesignal.debug.internal.logging.Logging +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean internal class RequestPermissionService( private val _application: IApplicationService, + @Suppress("ConstructorParameterNaming") + private val _preferences: IPreferencesService, ) : IRequestPermissionService { var waiting = false var fallbackToSettings = false var shouldShowRequestPermissionRationaleBeforeRequest = false private val callbackMap = HashMap() + private val hostPromptsInFlight = ConcurrentHashMap() + + val resultHandler: PermissionsResultHandler by lazy { PermissionsResultHandler(this, _preferences) } override fun registerAsCallback( permissionType: String, @@ -46,27 +58,162 @@ internal class RequestPermissionService( // an activity becomes available, until our permission activity is the one that's // available. _application.addActivityLifecycleHandler( - object : IActivityLifecycleHandler { - override fun onActivityAvailable(activity: Activity) { - if (activity.javaClass == PermissionsActivity::class.java) { - _application.removeActivityLifecycleHandler(this) - } else { - val intent = Intent(activity, PermissionsActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT - intent.putExtra(PermissionsViewModel.INTENT_EXTRA_PERMISSION_TYPE, permissionRequestType) - .putExtra(PermissionsViewModel.INTENT_EXTRA_ANDROID_PERMISSION_STRING, androidPermissionString) - .putExtra(PermissionsViewModel.INTENT_EXTRA_CALLBACK_CLASS, callbackClass.name) - activity.startActivity(intent) - activity.overridePendingTransition( - R.anim.onesignal_fade_in, - R.anim.onesignal_fade_out, - ) - } - } + Prompt(permissionRequestType, androidPermissionString, callbackClass, fallbackCondition), + ) + } + + /** + * One prompt attempt. All request state lives here rather than on the service so that + * overlapping prompts cannot overwrite each other's settings fallback or rationale. + */ + internal inner class Prompt( + private val permissionRequestType: String?, + private val permission: String?, + private val callbackClass: Class<*>, + private val fallbackToSettings: Boolean, + ) : IActivityLifecycleHandler { + private val completed = AtomicBoolean(false) + private var hostPrompt: HostPrompt? = null + + @Suppress("ReturnCount") + override fun onActivityAvailable(activity: Activity) { + if (completed.get()) { + return + } + + val host = hostPrompt + if (host != null) { + runOnMainThread { host.rebind(activity) } + return + } + + if (activity is PermissionsActivity) { + _application.removeActivityLifecycleHandler(this) + return + } + + launchPermissionsActivity(activity) + } + + override fun onActivityStopped(activity: Activity) { + } + + private fun launchPermissionsActivity(activity: Activity) { + val intent = Intent(activity, PermissionsActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + intent.putExtra(PermissionsViewModel.INTENT_EXTRA_PERMISSION_TYPE, permissionRequestType) + .putExtra(PermissionsViewModel.INTENT_EXTRA_ANDROID_PERMISSION_STRING, permission) + .putExtra(PermissionsViewModel.INTENT_EXTRA_CALLBACK_CLASS, callbackClass.name) + + // An explicit component resolves even after tools:node drops the activity. + try { + activity.startActivity(intent) + activity.overridePendingTransition( + R.anim.onesignal_fade_in, + R.anim.onesignal_fade_out, + ) + } catch (e: ActivityNotFoundException) { + onPermissionsActivityMissing(activity, e) + } catch (e: SecurityException) { + onPermissionsActivityMissing(activity, e) + } + } + + /** + * Falls back to prompting on the host's own activity, which survives `tools:node="replace"`. + * Only when no host can report a grant back do we complete the prompt as denied. + */ + private fun onPermissionsActivityMissing( + activity: Activity, + cause: Throwable?, + ) { + Logging.warn( + "PermissionsActivity is missing from the merged manifest. " + + " drops library activities. " + + "Use tools:replace on the specific attribute instead.", + cause, + ) + + val wrapper = OneSignalWrapper.hostPermissionPrompt + if (permission == null || (wrapper == null && activity !is ComponentActivity)) { + Logging.error("No host activity can prompt for $permission. Completing the prompt as denied.") + completeAsDenied() + return + } + + // One callback per permission type. Rejecting here would resolve the prompt already on screen. + if (hostPromptsInFlight.putIfAbsent(permission, this) != null) { + Logging.warn("A host prompt for $permission is already in flight.") + claimCompletion() + return + } - override fun onActivityStopped(activity: Activity) { + val host = + HostPrompt( + permission, + wrapper, + { _application.current }, + { completed.get() }, + ) { granted, rationaleAfter -> completeWith(granted, rationaleAfter) } + hostPrompt = host + + runOnMainThread { + val prompted = runCatching { host.start(activity) } + if (prompted.isFailure) { + // A prompt that throws would otherwise strand the caller suspended forever. + Logging.error("Host permission prompt failed for $permission.", prompted.exceptionOrNull()) } - }, - ) + if (prompted.getOrDefault(false).not()) { + completeAsDenied() + } + } + } + + private fun completeWith( + granted: Boolean, + rationaleAfter: Boolean?, + ) { + val host = hostPrompt + if (!claimCompletion()) { + return + } + resultHandler.handleResult( + PermissionPromptRequest( + permissionRequestType, + permission, + fallbackToSettings, + host?.rationaleBefore ?: false, + ), + granted, + rationaleAfter, + ) + } + + private fun completeAsDenied() { + if (!claimCompletion()) { + return + } + // No dialog was shown, so there is no rationale transition to treat as a fresh denial. + resultHandler.handleResult( + PermissionPromptRequest( + permissionRequestType, + permission, + fallbackToSettings, + hostPrompt?.rationaleBefore ?: false, + ), + false, + null, + dialogShown = false, + ) + } + + private fun claimCompletion(): Boolean { + if (!completed.compareAndSet(false, true)) { + return false + } + _application.removeActivityLifecycleHandler(this) + permission?.let { hostPromptsInFlight.remove(it, this) } + return true + } } } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/InFlightResultTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/InFlightResultTests.kt new file mode 100644 index 0000000000..4ea317a832 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/InFlightResultTests.kt @@ -0,0 +1,63 @@ +package com.onesignal.common.threading + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration.Companion.seconds + +class InFlightResultTests : FunSpec({ + test("cancelling the owner while the lock is held does not stick the fallback") { + val shared = InFlightResult() + val gate = + InFlightResult::class.java.getDeclaredField("gate").apply { isAccessible = true }.get(shared) as Mutex + val started = CompletableDeferred() + val holding = CompletableDeferred() + val release = CompletableDeferred() + val scope = CoroutineScope(Dispatchers.Default) + val owner = + scope.launch { + shared.share(false) { + started.complete(Unit) + suspendCancellableCoroutine { } + true + } + } + + started.await() + val holder = + scope.launch { + gate.lock() + holding.complete(Unit) + release.await() + gate.unlock() + } + holding.await() + owner.cancel() + // A cancelled owner that gives up on the held lock finishes here. The fix waits. + val abandoned = withTimeoutOrNull(1.seconds) { owner.join() } + release.complete(Unit) + holder.join() + if (abandoned == null) { + owner.join() + } + + var ran = false + val next = + shared.share(false) { + ran = true + true + } + + abandoned shouldBe null + next shouldBe true + ran shouldBe true + scope.cancel() + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt index 2139392f01..5f17900328 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt @@ -3,6 +3,7 @@ package com.onesignal.core.internal.permissions import android.app.Activity import android.content.pm.PackageManager import com.onesignal.OneSignal +import com.onesignal.core.internal.permissions.impl.PermissionsResultHandler import com.onesignal.core.internal.permissions.impl.RequestPermissionService import com.onesignal.core.internal.preferences.IPreferencesService import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys @@ -43,6 +44,7 @@ class PermissionsViewModelTests : FunSpec({ mockkObject(OneSignal) every { OneSignal.getService() } returns mockRequestService every { OneSignal.getService() } returns mockPrefService + every { mockRequestService.resultHandler } returns PermissionsResultHandler(mockRequestService, mockPrefService) Logging.logLevel = LogLevel.NONE } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/RequestPermissionServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/RequestPermissionServiceTests.kt new file mode 100644 index 0000000000..b5fcd0c49d --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/RequestPermissionServiceTests.kt @@ -0,0 +1,440 @@ +package com.onesignal.core.internal.permissions + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContract +import androidx.core.app.ActivityOptionsCompat +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.common.IHostPermissionPrompt +import com.onesignal.common.OneSignalWrapper +import com.onesignal.core.activities.PermissionsActivity +import com.onesignal.core.internal.application.IActivityLifecycleHandler +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.permissions.impl.RequestPermissionService +import com.onesignal.core.internal.preferences.IPreferencesService +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify + +@RobolectricTest +class RequestPermissionServiceTests : FunSpec({ + beforeTest { + Logging.logLevel = LogLevel.NONE + OneSignalWrapper.hostPermissionPrompt = null + } + + afterTest { + OneSignalWrapper.hostPermissionPrompt = null + } + + test("startPrompt launches PermissionsActivity when it is resolvable") { + val env = Env() + + env.service.startPrompt(false, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handler.onActivityAvailable(env.hostActivity) + + verify(exactly = 1) { env.hostActivity.startActivity(any()) } + verify(exactly = 0) { env.callback.onReject(any()) } + verify(exactly = 0) { env.app.removeActivityLifecycleHandler(any()) } + } + + test("startPrompt rejects and unsubscribes when no host can prompt") { + val env = Env() + every { env.hostActivity.startActivity(any()) } throws ActivityNotFoundException("missing") + + env.service.startPrompt(true, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handler.onActivityAvailable(env.hostActivity) + + verify(exactly = 1) { env.hostActivity.startActivity(any()) } + verify(exactly = 1) { env.callback.onReject(false) } + verify(exactly = 1) { env.app.removeActivityLifecycleHandler(env.handler) } + verify(exactly = 0) { + env.preferences.saveBool(any(), match { it.startsWith(PROMPTED_PREFIX) }, any()) + } + } + + test("a host the SDK is not allowed to start still completes the prompt as denied") { + val env = Env() + every { env.hostActivity.startActivity(any()) } throws SecurityException("blocked") + + env.service.startPrompt(true, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handler.onActivityAvailable(env.hostActivity) + + verify(exactly = 1) { env.callback.onReject(false) } + verify(exactly = 0) { + env.preferences.saveBool(any(), match { it.startsWith(PROMPTED_PREFIX) }, any()) + } + } + + test("a host that cannot prompt still offers settings when the permission was already resolved") { + val env = Env() + env.markResolved() + every { env.hostActivity.startActivity(any()) } throws ActivityNotFoundException("missing") + + env.service.startPrompt(true, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handler.onActivityAvailable(env.hostActivity) + + verify(exactly = 1) { env.callback.onReject(true) } + } + + test("startPrompt removes the handler when PermissionsActivity is already current") { + val env = Env() + val permissionsActivity = mockk(relaxed = true) + + env.service.startPrompt(false, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handler.onActivityAvailable(permissionsActivity) + + verify(exactly = 0) { permissionsActivity.startActivity(any()) } + verify(exactly = 0) { env.callback.onReject(any()) } + verify(exactly = 1) { env.app.removeActivityLifecycleHandler(env.handler) } + } + + test("startPrompt is a no-op while waiting") { + val env = Env() + env.service.waiting = true + + env.service.startPrompt(false, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + + verify(exactly = 0) { env.app.addActivityLifecycleHandler(any()) } + } + + test("a granted wrapper host prompt accepts instead of crashing") { + val env = Env() + every { env.hostActivity.startActivity(any()) } throws ActivityNotFoundException("missing") + val prompt = RecordingPrompt(granted = true) + OneSignalWrapper.hostPermissionPrompt = prompt + + env.service.startPrompt(true, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handler.onActivityAvailable(env.hostActivity) + + prompt.requested shouldBe ANDROID_PERMISSION + verify(exactly = 1) { env.callback.onAccept() } + verify(exactly = 0) { env.callback.onReject(any()) } + } + + test("a denied wrapper host prompt reports the settings fallback decision") { + val env = Env() + env.markPreviouslyPrompted() + every { env.hostActivity.startActivity(any()) } throws ActivityNotFoundException("missing") + val prompt = RecordingPrompt(granted = false) + OneSignalWrapper.hostPermissionPrompt = prompt + + env.service.startPrompt(true, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handler.onActivityAvailable(env.hostActivity) + + prompt.requested shouldBe ANDROID_PERMISSION + verify(exactly = 1) { env.callback.onReject(true) } + verify(exactly = 0) { env.callback.onAccept() } + } + + test("a wrapper host prompt with no activity completes the prompt as denied") { + val env = Env() + every { env.hostActivity.startActivity(any()) } throws ActivityNotFoundException("missing") + OneSignalWrapper.hostPermissionPrompt = DecliningPrompt() + + env.service.startPrompt(true, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handler.onActivityAvailable(env.hostActivity) + + verify(exactly = 1) { env.callback.onReject(false) } + verify(exactly = 0) { env.callback.onAccept() } + } + + test("a wrapper host prompt that throws completes the prompt as denied") { + val env = Env() + every { env.hostActivity.startActivity(any()) } throws ActivityNotFoundException("missing") + OneSignalWrapper.hostPermissionPrompt = ThrowingPrompt() + + env.service.startPrompt(true, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handler.onActivityAvailable(env.hostActivity) + + verify(exactly = 1) { env.callback.onReject(false) } + } + + test("a wrapper that declines still rebinds the registry after the host is recreated") { + val env = Env() + OneSignalWrapper.hostPermissionPrompt = DecliningPrompt() + val registry = TestRegistry(result = null) + + env.service.startPrompt(false, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handler.onActivityAvailable(env.componentActivity(registry)) + + val savedState = Bundle() + registry.onSaveInstanceState(savedState) + val recreatedRegistry = TestRegistry(result = null) + recreatedRegistry.onRestoreInstanceState(savedState) + recreatedRegistry.dispatchResult(registry.launched.single(), true) + + env.handler.onActivityAvailable(env.componentActivity(recreatedRegistry)) + + verify(exactly = 1) { env.callback.onAccept() } + recreatedRegistry.launched.shouldBeEmpty() + } + + test("a successful wrapper host prompt does not also prompt on the registry") { + val env = Env() + val registry = TestRegistry(result = true) + val activity = env.componentActivity(registry) + OneSignalWrapper.hostPermissionPrompt = RecordingPrompt(granted = true) + + env.service.startPrompt(true, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handler.onActivityAvailable(activity) + + registry.launched.shouldBeEmpty() + verify(exactly = 1) { env.callback.onAccept() } + } + + test("a ComponentActivity host prompts through its own result registry") { + val env = Env() + val registry = TestRegistry(result = true) + val activity = env.componentActivity(registry) + + env.service.startPrompt(true, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handler.onActivityAvailable(activity) + + registry.lastInput shouldBe ANDROID_PERMISSION + verify(exactly = 1) { env.callback.onAccept() } + verify(exactly = 0) { env.callback.onReject(any()) } + } + + test("a denied ComponentActivity host prompt reports the settings fallback decision") { + val env = Env() + env.markPreviouslyPrompted() + val registry = TestRegistry(result = false) + val activity = env.componentActivity(registry) + + env.service.startPrompt(true, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handler.onActivityAvailable(activity) + + registry.lastInput shouldBe ANDROID_PERMISSION + verify(exactly = 1) { env.callback.onReject(true) } + verify(exactly = 0) { env.callback.onAccept() } + verify(exactly = 1) { + env.preferences.saveBool(any(), match { it.startsWith(PROMPTED_PREFIX) }, true) + } + } + + test("a result that arrives after the host activity is recreated still reaches the caller") { + val env = Env() + val registry = TestRegistry(result = null) + val activity = env.componentActivity(registry) + + env.service.startPrompt(false, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handler.onActivityAvailable(activity) + + // The host is recreated: androidx restores the key but not our callback, and the OS + // delivers the answer into the fresh registry before we get a chance to rebind. + val savedState = Bundle() + registry.onSaveInstanceState(savedState) + val recreatedRegistry = TestRegistry(result = null) + recreatedRegistry.onRestoreInstanceState(savedState) + recreatedRegistry.dispatchResult(registry.launched.single(), true) + + env.handler.onActivityAvailable(env.componentActivity(recreatedRegistry)) + + verify(exactly = 1) { env.callback.onAccept() } + recreatedRegistry.launched.shouldBeEmpty() + } + + test("a config change does not launch a second request while the first is in flight") { + val env = Env() + val registry = TestRegistry(result = null) + val activity = env.componentActivity(registry) + every { activity.isChangingConfigurations } returns true + + env.service.startPrompt(false, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handler.onActivityAvailable(activity) + + val savedState = Bundle() + registry.onSaveInstanceState(savedState) + val recreatedRegistry = TestRegistry(result = null) + recreatedRegistry.onRestoreInstanceState(savedState) + + env.handler.onActivityAvailable(env.componentActivity(recreatedRegistry)) + + recreatedRegistry.launched.shouldBeEmpty() + verify(exactly = 0) { env.callback.onReject(any()) } + + recreatedRegistry.dispatchResult(registry.launched.single(), true) + verify(exactly = 1) { env.callback.onAccept() } + } + + test("a replaced host activity with no pending answer prompts again") { + val env = Env() + val registry = TestRegistry(result = null) + + env.service.startPrompt(false, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handler.onActivityAvailable(env.componentActivity(registry)) + + val replacementRegistry = TestRegistry(result = true) + env.handler.onActivityAvailable(env.componentActivity(replacementRegistry)) + + replacementRegistry.lastInput shouldBe ANDROID_PERMISSION + verify(exactly = 1) { env.callback.onAccept() } + } + + test("two host prompts in flight each keep their own settings fallback") { + val env = Env() + env.markPreviouslyPrompted() + val locationCallback = mockk(relaxed = true) + env.service.registerAsCallback(LOCATION_PERMISSION_TYPE, locationCallback) + + val registry = TestRegistry(result = null) + val activity = env.componentActivity(registry) + + // Only the notification prompt asks for the settings fallback. + env.service.startPrompt(true, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handlers[0].onActivityAvailable(activity) + env.service.startPrompt(false, LOCATION_PERMISSION_TYPE, LOCATION_PERMISSION, Env.Callback::class.java) + env.handlers[1].onActivityAvailable(activity) + + registry.launched.distinct().size shouldBe 2 + registry.launched.forEach { registry.dispatchResult(it, false) } + + verify(exactly = 1) { env.callback.onReject(true) } + verify(exactly = 1) { locationCallback.onReject(false) } + } + + test("an answer parked by a dead process is delivered without prompting again") { + // The first process launches and dies before the user answers. + val first = Env() + val registry = TestRegistry(result = null) + first.service.startPrompt(false, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + first.handler.onActivityAvailable(first.componentActivity(registry)) + + val savedState = Bundle() + registry.onSaveInstanceState(savedState) + + // The new process restores the key, and the OS answer parks because nothing is registered. + val restoredRegistry = TestRegistry(result = null) + restoredRegistry.onRestoreInstanceState(savedState) + restoredRegistry.dispatchResult(registry.launched.single(), true) + + val second = Env() + second.service.startPrompt(false, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + second.handler.onActivityAvailable(second.componentActivity(restoredRegistry)) + + verify(exactly = 1) { second.callback.onAccept() } + restoredRegistry.launched.shouldBeEmpty() + } + + test("a second prompt for the same permission leaves the in-flight prompt to answer") { + val env = Env() + val registry = TestRegistry(result = null) + val activity = env.componentActivity(registry) + + env.service.startPrompt(false, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handlers[0].onActivityAvailable(activity) + env.service.startPrompt(true, PERMISSION_TYPE, ANDROID_PERMISSION, Env.Callback::class.java) + env.handlers[1].onActivityAvailable(activity) + + registry.launched.size shouldBe 1 + verify(exactly = 0) { env.callback.onReject(any()) } + + registry.dispatchResult(registry.launched.single(), true) + verify(exactly = 1) { env.callback.onAccept() } + verify(exactly = 0) { env.callback.onReject(any()) } + } +}) + +private const val PERMISSION_TYPE = "NOTIFICATION" +private const val ANDROID_PERMISSION = "android.permission.POST_NOTIFICATIONS" +private const val LOCATION_PERMISSION_TYPE = "LOCATION" +private const val LOCATION_PERMISSION = "android.permission.ACCESS_FINE_LOCATION" +private const val PROMPTED_PREFIX = "PROMPTED_PERMISSION_" +private const val RESOLVED_PREFIX = "USER_RESOLVED_PERMISSION_" + +private fun List.shouldBeEmpty() = isEmpty() shouldBe true + +/** Dispatches inline when [result] is set, otherwise holds the request so the test can drive it. */ +private class TestRegistry(private val result: Boolean?) : ActivityResultRegistry() { + val launched = mutableListOf() + var lastInput: Any? = null + + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) { + launched.add(requestCode) + lastInput = input + + @Suppress("UNCHECKED_CAST") + result?.let { dispatchResult(requestCode, it as O) } + } +} + +private class RecordingPrompt(private val granted: Boolean) : IHostPermissionPrompt { + var requested: String? = null + + override fun request( + androidPermission: String, + callback: IHostPermissionPrompt.Callback, + ): Boolean { + requested = androidPermission + callback.onResult(granted) + return true + } +} + +private class DecliningPrompt : IHostPermissionPrompt { + override fun request( + androidPermission: String, + callback: IHostPermissionPrompt.Callback, + ): Boolean = false +} + +private class ThrowingPrompt : IHostPermissionPrompt { + override fun request( + androidPermission: String, + callback: IHostPermissionPrompt.Callback, + ): Boolean = throw IllegalStateException("boom") +} + +private class Env { + interface Callback : IRequestPermissionService.PermissionCallback + + val callback = mockk(relaxed = true) + val app = mockk(relaxed = true) + val preferences = mockk(relaxed = true) + val hostActivity = mockk(relaxed = true) + val handlers = mutableListOf() + val service = RequestPermissionService(app, preferences) + + val handler: IActivityLifecycleHandler + get() = handlers.last() + + init { + every { app.addActivityLifecycleHandler(capture(handlers)) } just runs + service.registerAsCallback(PERMISSION_TYPE, callback) + } + + /** A ComponentActivity host whose PermissionsActivity launch fails, as a stripped manifest does. */ + fun componentActivity(registry: ActivityResultRegistry): ComponentActivity { + val activity = mockk(relaxed = true) + every { activity.startActivity(any()) } throws ActivityNotFoundException("missing") + every { activity.activityResultRegistry } returns registry + return activity + } + + /** + * Makes the settings fallback reachable: a denial only offers settings once OneSignal has + * prompted for that permission before. + */ + fun markPreviouslyPrompted() { + every { preferences.getBool(any(), match { it.startsWith(PROMPTED_PREFIX) }, any()) } returns true + } + + fun markResolved() { + every { preferences.getBool(any(), match { it.startsWith(RESOLVED_PREFIX) }, any()) } returns true + } +} diff --git a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/permissions/LocationPermissionController.kt b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/permissions/LocationPermissionController.kt index 4a4e41636b..e7e5d05f00 100644 --- a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/permissions/LocationPermissionController.kt +++ b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/permissions/LocationPermissionController.kt @@ -31,6 +31,7 @@ import android.app.Activity import com.onesignal.common.AndroidUtils import com.onesignal.common.events.EventProducer import com.onesignal.common.events.IEventNotifier +import com.onesignal.common.threading.InFlightResult import com.onesignal.common.threading.WaiterWithValue import com.onesignal.core.activities.PermissionsActivity import com.onesignal.core.internal.application.ApplicationLifecycleHandlerBase @@ -56,6 +57,7 @@ internal class LocationPermissionController( } private val waiter = WaiterWithValue() + private val inFlightPrompt = InFlightResult() private val events = EventProducer() private var currPermission: String = "" @@ -67,17 +69,16 @@ internal class LocationPermissionController( fallbackToSettings: Boolean, androidPermissionString: String, ): Boolean { - currPermission = androidPermissionString - _requestPermission.startPrompt( - fallbackToSettings, - PERMISSION_TYPE, - androidPermissionString, - this::class.java, - ) - - // this won't return until onAccept or onReject sends the response on the channel (either - // through the native prompt or through the fallback) - return waiter.waitForWake() + return inFlightPrompt.share(false) { + currPermission = androidPermissionString + _requestPermission.startPrompt( + fallbackToSettings, + PERMISSION_TYPE, + androidPermissionString, + this::class.java, + ) + waiter.waitForWake() + } } override fun onAccept() { diff --git a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/permissions/LocationPermissionControllerTests.kt b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/permissions/LocationPermissionControllerTests.kt index 8f16efc3f6..2366c51f13 100644 --- a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/permissions/LocationPermissionControllerTests.kt +++ b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/permissions/LocationPermissionControllerTests.kt @@ -23,7 +23,12 @@ import io.mockk.slot import io.mockk.spyk import io.mockk.unmockkObject import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.concurrent.thread +import kotlin.time.Duration.Companion.seconds @RobolectricTest class LocationPermissionControllerTests : FunSpec({ @@ -59,6 +64,38 @@ class LocationPermissionControllerTests : FunSpec({ verify(exactly = 1) { mockRequestPermissionService.startPrompt(false, any(), "permission", any()) } } + test("a second prompt joins the one already in flight") { + val mockRequestPermissionService = mockk() + val locationPermissionController = + LocationPermissionController( + mockRequestPermissionService, + AndroidMockHelper.applicationService(), + ) + + val second = CompletableDeferred() + var started = false + every { mockRequestPermissionService.startPrompt(any(), any(), any(), any()) } answers { + if (!started) { + started = true + thread { + second.complete(runBlocking { locationPermissionController.prompt(true, "permission") }) + } + Thread.sleep(200) + } + locationPermissionController.onAccept() + } + + val first = + withTimeout(5.seconds) { + val owner = locationPermissionController.prompt(false, "permission") + val joined = second.await() + owner to joined + } + + first shouldBe (true to true) + verify(exactly = 1) { mockRequestPermissionService.startPrompt(any(), any(), any(), any()) } + } + test("prompt will return false once permission is rejected by user") { // Given val mockRequestPermissionService = mockk() diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/permissions/impl/NotificationPermissionController.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/permissions/impl/NotificationPermissionController.kt index ef387a6e05..0c42d634be 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/permissions/impl/NotificationPermissionController.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/permissions/impl/NotificationPermissionController.kt @@ -32,6 +32,7 @@ import android.os.Build import androidx.annotation.ChecksSdkIntAtLeast import com.onesignal.common.AndroidUtils import com.onesignal.common.events.EventProducer +import com.onesignal.common.threading.InFlightResult import com.onesignal.common.threading.Waiter import com.onesignal.common.threading.WaiterWithValue import com.onesignal.common.threading.launchOnIO @@ -62,6 +63,7 @@ internal class NotificationPermissionController( ) : IRequestPermissionService.PermissionCallback, INotificationPermissionController { private val waiter = WaiterWithValue() + private val inFlightPrompt = InFlightResult() private val pollingWaiter = Waiter() private var pollingWaitInterval: Long private val events = EventProducer() @@ -135,16 +137,7 @@ internal class NotificationPermissionController( } /** - * Prompt the user for notification permission. Note it is possible the application - * will be killed while the permission prompt is being displayed to the user. When the - * app restarts it will begin with the permission prompt. In this case this suspending - * function has been killed as well, the permission callbacks should be used to cover - * that case. - * - * @return true if permissions are enabled. False if they are not enabled, null if the user - * was directed to the permission settings and could not be determined at this time. When this - * does happen, the app will detect the permissions on app focus and drive permission callbacks - * to notify of the status. + * A second call joins the prompt already on screen. Starting another would resolve the first. */ override suspend fun prompt(fallbackToSettings: Boolean): Boolean { // Calling yield() to force a suspension point because Kotlin Continuation won't work @@ -155,22 +148,23 @@ internal class NotificationPermissionController( return true } - if (supportsNativePrompt) { - _requestPermission.startPrompt( - fallbackToSettings, - PERMISSION_TYPE, - ANDROID_PERMISSION_STRING, - this::class.java, - ) - } else if (fallbackToSettings) { - showFallbackAlertDialog() - } else { + if (!supportsNativePrompt && !fallbackToSettings) { return false } - // this won't return until onAccept or onReject sends the response on the channel (either - // through the native prompt or through the fallback) - return waiter.waitForWake() + return inFlightPrompt.share(false) { + if (supportsNativePrompt) { + _requestPermission.startPrompt( + fallbackToSettings, + PERMISSION_TYPE, + ANDROID_PERMISSION_STRING, + this::class.java, + ) + } else { + showFallbackAlertDialog() + } + waiter.waitForWake() + } } override fun subscribe(handler: INotificationPermissionChangedHandler) = events.subscribe(handler) From 7ec595ca60bed2410b46d4ffc6d8f2b065215614 Mon Sep 17 00:00:00 2001 From: abdulraqeeb33 Date: Mon, 12 Oct 2026 09:44:37 -0500 Subject: [PATCH 02/20] chore: [SDK-4986] gate public API changes with binary-compatibility-validator (#2778) Co-authored-by: AR Abdul Azeez Co-authored-by: Cursor --- .github/workflows/ci.yml | 18 + CONTRIBUTING.md | 23 + OneSignalSDK/build.gradle | 58 +- OneSignalSDK/onesignal/api/OneSignal.api | 0 OneSignalSDK/onesignal/core/api/core.api | 518 ++++++++++++++++++ .../com/onesignal/common/OneSignalWrapper.kt | 1 + .../debug/internal/logging/Logging.kt | 2 + .../in-app-messages/api/in-app-messages.api | 0 .../onesignal/location/api/location.api | 0 .../notifications/api/notifications.api | 74 +++ 10 files changed, 693 insertions(+), 1 deletion(-) create mode 100644 OneSignalSDK/onesignal/api/OneSignal.api create mode 100644 OneSignalSDK/onesignal/core/api/core.api create mode 100644 OneSignalSDK/onesignal/in-app-messages/api/in-app-messages.api create mode 100644 OneSignalSDK/onesignal/location/api/location.api create mode 100644 OneSignalSDK/onesignal/notifications/api/notifications.api diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67fe01bbc0..bf59ab8565 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,24 @@ jobs: working-directory: OneSignalSDK run: | ./gradlew detekt --console=plain + # Explicit paths: a bare `apiCheck` silently skips any module that stops registering the task. + - name: "[Public API] Binary compatibility check" + id: api_check + working-directory: OneSignalSDK + run: | + ./gradlew \ + :OneSignal:apiCheck \ + :OneSignal:core:apiCheck \ + :OneSignal:notifications:apiCheck \ + :OneSignal:in-app-messages:apiCheck \ + :OneSignal:location:apiCheck \ + --console=plain + - name: "[Public API] Explain a failure" + if: failure() && steps.api_check.outcome == 'failure' + run: | + echo "If apiCheck failed, the public API changed. Review the diff it printed." + echo "If the change is intended, run './gradlew apiDump' in OneSignalSDK and commit the updated api/*.api files." + echo "If the new lines are internal (not customer or wrapper API), add them to ignoredPackages or ignoredClasses in OneSignalSDK/build.gradle instead." test: runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 049ac834b6..7d5c8f4020 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,6 +68,29 @@ flowchart TD The only manual actions in Flow B are **Run workflow** and **merge the bump PR**; everything in between is automated. +### Public API changes + +The published modules track their public API in committed dumps under `/api/*.api`, generated by the [binary compatibility validator](https://github.com/Kotlin/binary-compatibility-validator). CI runs `apiCheck` alongside Spotless and Detekt, so any change to the public surface fails the build until the dump is regenerated. The point is that a breaking change has to appear as a reviewable diff rather than be caught by eye. + +If you change the public API on purpose, regenerate and commit the dumps: + +```bash +cd OneSignalSDK +./gradlew apiDump +``` + +Then read the resulting diff before committing it. A removed or altered line is a breaking change for customers. An added line is usually safe, with one important exception: a new method on an interface customers implement (listeners such as `INotificationClickListener` or `IUserJwtInvalidatedListener`) breaks every existing implementation. + +Two things about the dumps are worth knowing up front: + +- They cover customer and wrapper-SDK API only. Kotlin has no cross-module "internal", so `com.onesignal.**.internal.*` and most of `com.onesignal.common.*` compile as public; `apiValidation` in `OneSignalSDK/build.gradle` excludes them so internal refactors don't trip `apiCheck`. `OneSignalWrapper`, `IHostPermissionPrompt`, and `debug.internal.logging.Logging` stay in because the wrapper SDKs (Flutter, React Native, Cordova, Capacitor, Unity, .NET) call them, so treat them as public API. If you add a new internal package, or a new class at the root of `com.onesignal.common`, add it to `ignoredPackages` or `ignoredClasses` rather than dumping it. +- `in-app-messages.api` and `location.api` are empty because their customer-facing interfaces live in `core`. That is expected, not a misconfiguration. `notifications.api` also records manifest components (receivers, activities, HMS/ADM services). +- `Logging.setLoggerTelemetry` takes a KMP type, so a KMP submodule bump (Flow B above) that changes `ILogTelemetryRemote` will fail `apiCheck`. Run `apiDump` on the bump PR if so. + +The validator hooks into the `kotlin-android` plugin, and CI calls each module's `apiCheck` by path. If a module moves to AGP 9's built-in Kotlin, its task stops being registered and CI fails with "task not found" rather than skipping it. See [BCV#312](https://github.com/Kotlin/binary-compatibility-validator/issues/312). + +The eventual replacement is the [ABI validation built into the Kotlin Gradle plugin](https://kotlinlang.org/docs/gradle-binary-compatibility-validation.html) (`kotlin { abiValidation { } }`, experimental since Kotlin 2.2). It keeps the same `api/*.api` dump format, so the move is mostly porting the `apiValidation` filters. It needs Kotlin 2.2+, so it is planned alongside the AGP 9 move for v6. + #### Before Submitting A Bug Report Before creating bug reports, please check this list of steps to follow. diff --git a/OneSignalSDK/build.gradle b/OneSignalSDK/build.gradle index 6a29fc4016..10a2b4c44d 100644 --- a/OneSignalSDK/build.gradle +++ b/OneSignalSDK/build.gradle @@ -26,6 +26,9 @@ buildscript { startupVersion = '1.1.1' ktlintVersion = '0.50.0' // Used by Spotless for Kotlin formatting (compatible with Kotlin 1.7.10) spotlessVersion = '6.25.0' + // BCV hooks into 'kotlin-android', so AGP 9's built-in Kotlin stops it registering tasks. + // See https://github.com/Kotlin/binary-compatibility-validator/issues/312 + binaryCompatibilityValidatorVersion = '0.16.3' tdunningJsonForTest = '1.0' // DO NOT upgrade for tests, using an old version so it matches AOSP sharedRepos = { @@ -44,7 +47,8 @@ buildscript { "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.vanniktech.maven.publish:com.vanniktech.maven.publish.gradle.plugin:0.32.0", + "org.jetbrains.kotlinx:binary-compatibility-validator:$binaryCompatibilityValidatorVersion" ] } @@ -54,6 +58,58 @@ buildscript { } } +apply plugin: 'org.jetbrains.kotlinx.binary-compatibility-validator' + +// Tracks the public API of the published modules as committed `api/*.api` dumps, so a breaking +// change has to show up as a reviewable diff instead of being spotted by eye. +apiValidation { + // Not published, so they have no customer-facing surface to protect. + ignoredProjects += ['app', 'testhelpers'] + // :kmp is a git submodule; its API is owned by OneSignal-KMP-SDK. + ignoredProjects += ['kmp'] + + // Public only because Kotlin has no cross-module internal. Subpackages are ignored too. + ignoredPackages += [ + 'com.onesignal.internal', + 'com.onesignal.core.internal', + // Not all of debug.internal: wrapper SDKs call debug.internal.logging.Logging. + 'com.onesignal.debug.internal.crash', + 'com.onesignal.debug.internal.logging.logger', + 'com.onesignal.inAppMessages.internal', + 'com.onesignal.location.internal', + 'com.onesignal.notifications.internal', + 'com.onesignal.session.internal', + 'com.onesignal.user.internal', + 'com.onesignal.common.consistency', + 'com.onesignal.common.events', + 'com.onesignal.common.exceptions', + 'com.onesignal.common.modeling', + 'com.onesignal.common.modules', + 'com.onesignal.common.services', + 'com.onesignal.common.threading', + ] + // Wrapper SDKs call OneSignalWrapper and IHostPermissionPrompt, so the rest of + // com.onesignal.common is ignored class by class. Nested classes need their own entry. + ignoredClasses += [ + 'com.onesignal.common.AndroidUtils', + 'com.onesignal.common.AndroidUtils$SchemaType', + 'com.onesignal.common.AndroidUtils$SchemaType$Companion', + 'com.onesignal.common.DateUtils', + 'com.onesignal.common.DeviceUtils', + 'com.onesignal.common.IDManager', + 'com.onesignal.common.InputGuardKt', + 'com.onesignal.common.JSONObjectExtensionsKt', + 'com.onesignal.common.JSONUtils', + 'com.onesignal.common.NetworkUtils', + 'com.onesignal.common.NetworkUtils$ResponseStatusType', + 'com.onesignal.common.OneSignalUtils', + 'com.onesignal.common.PIIHasher', + 'com.onesignal.common.RootToolsInternalMethods', + 'com.onesignal.common.TimeUtils', + 'com.onesignal.common.ViewUtils', + ] +} + allprojects { repositories { google() diff --git a/OneSignalSDK/onesignal/api/OneSignal.api b/OneSignalSDK/onesignal/api/OneSignal.api new file mode 100644 index 0000000000..e69de29bb2 diff --git a/OneSignalSDK/onesignal/core/api/core.api b/OneSignalSDK/onesignal/core/api/core.api new file mode 100644 index 0000000000..432f1d6490 --- /dev/null +++ b/OneSignalSDK/onesignal/core/api/core.api @@ -0,0 +1,518 @@ +public final class com/onesignal/Continue { + public static final field INSTANCE Lcom/onesignal/Continue; + public static final fun none ()Lkotlin/coroutines/Continuation; + public static final fun with (Ljava/util/function/Consumer;)Lkotlin/coroutines/Continuation; + public static final fun with (Ljava/util/function/Consumer;Lkotlin/coroutines/CoroutineContext;)Lkotlin/coroutines/Continuation; + public static synthetic fun with$default (Ljava/util/function/Consumer;Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlin/coroutines/Continuation; +} + +public final class com/onesignal/ContinueResult { + public fun (ZLjava/lang/Object;Ljava/lang/Throwable;)V + public final fun getData ()Ljava/lang/Object; + public final fun getThrowable ()Ljava/lang/Throwable; + public final fun isSuccess ()Z +} + +public abstract interface class com/onesignal/IOneSignal { + public abstract fun addUserJwtInvalidatedListener (Lcom/onesignal/IUserJwtInvalidatedListener;)V + public abstract fun getConsentGiven ()Z + public abstract fun getConsentGiven (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getConsentRequired ()Z + public abstract fun getConsentRequired (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getDebug ()Lcom/onesignal/debug/IDebugManager; + public abstract fun getDisableGMSMissingPrompt ()Z + public abstract fun getDisableGMSMissingPrompt (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getInAppMessages ()Lcom/onesignal/inAppMessages/IInAppMessagesManager; + public abstract fun getInAppMessages (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getLocation ()Lcom/onesignal/location/ILocationManager; + public abstract fun getLocation (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getNotifications ()Lcom/onesignal/notifications/INotificationsManager; + public abstract fun getNotifications (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getSdkVersion ()Ljava/lang/String; + public abstract fun getSession ()Lcom/onesignal/session/ISessionManager; + public abstract fun getSession (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getUser ()Lcom/onesignal/user/IUserManager; + public abstract fun getUser (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun initWithContext (Landroid/content/Context;Ljava/lang/String;)Z + public abstract fun initWithContext (Landroid/content/Context;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun initWithContextSuspend (Landroid/content/Context;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun isInitialized ()Z + public abstract fun login (Ljava/lang/String;)V + public abstract fun login (Ljava/lang/String;Ljava/lang/String;)V + public abstract fun loginSuspend (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun logout ()V + public abstract fun logoutSuspend (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun removeUserJwtInvalidatedListener (Lcom/onesignal/IUserJwtInvalidatedListener;)V + public abstract fun setConsentGiven (Z)V + public abstract fun setConsentGiven (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setConsentRequired (Z)V + public abstract fun setConsentRequired (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setDisableGMSMissingPrompt (Z)V + public abstract fun setDisableGMSMissingPrompt (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateUserJwt (Ljava/lang/String;Ljava/lang/String;)V + public abstract fun updateUserJwtSuspend (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/onesignal/IOneSignal$DefaultImpls { + public static synthetic fun initWithContextSuspend$default (Lcom/onesignal/IOneSignal;Landroid/content/Context;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static fun login (Lcom/onesignal/IOneSignal;Ljava/lang/String;)V + public static synthetic fun login$default (Lcom/onesignal/IOneSignal;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V + public static synthetic fun loginSuspend$default (Lcom/onesignal/IOneSignal;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public abstract interface class com/onesignal/IUserJwtInvalidatedListener { + public abstract fun onUserJwtInvalidated (Lcom/onesignal/UserJwtInvalidatedEvent;)V +} + +public final class com/onesignal/OneSignal { + public static final field INSTANCE Lcom/onesignal/OneSignal; + public static final fun addUserJwtInvalidatedListener (Lcom/onesignal/IUserJwtInvalidatedListener;)V + public static final fun getConsentGiven ()Z + public static final fun getConsentGivenSuspend (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun getConsentRequired ()Z + public static final fun getConsentRequiredSuspend (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun getDebug ()Lcom/onesignal/debug/IDebugManager; + public static final fun getDisableGMSMissingPrompt ()Z + public static final fun getDisableGMSMissingPromptSuspend (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun getInAppMessages ()Lcom/onesignal/inAppMessages/IInAppMessagesManager; + public static final fun getInAppMessagesSuspend (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun getLocation ()Lcom/onesignal/location/ILocationManager; + public static final fun getLocationSuspend (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun getNotifications ()Lcom/onesignal/notifications/INotificationsManager; + public static final fun getNotificationsSuspend (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun getSdkVersion ()Ljava/lang/String; + public final fun getServices ()Lcom/onesignal/common/services/IServiceProvider; + public static final fun getSession ()Lcom/onesignal/session/ISessionManager; + public static final fun getSessionSuspend (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun getUser ()Lcom/onesignal/user/IUserManager; + public static final fun getUserSuspend (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun initWithContext (Landroid/content/Context;Ljava/lang/String;)V + public static final fun initWithContext (Landroid/content/Context;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun initWithContextSuspend (Landroid/content/Context;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun initWithContextSuspend$default (Landroid/content/Context;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun isInitialized ()Z + public static final fun login (Ljava/lang/String;)V + public static final fun login (Ljava/lang/String;Ljava/lang/String;)V + public static synthetic fun login$default (Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V + public static final fun loginSuspend (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun loginSuspend$default (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun logout ()V + public static final fun logoutSuspend (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun removeUserJwtInvalidatedListener (Lcom/onesignal/IUserJwtInvalidatedListener;)V + public static final fun setConsentGiven (Z)V + public static final fun setConsentGivenSuspend (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setConsentRequired (Z)V + public static final fun setConsentRequiredSuspend (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setDisableGMSMissingPrompt (Z)V + public static final fun setDisableGMSMissingPromptSuspend (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun updateUserJwt (Ljava/lang/String;Ljava/lang/String;)V + public static final fun updateUserJwtSuspend (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/onesignal/SyncJobService : android/app/job/JobService { + public fun ()V + public fun onStartJob (Landroid/app/job/JobParameters;)Z + public fun onStopJob (Landroid/app/job/JobParameters;)Z +} + +public final class com/onesignal/UserJwtInvalidatedEvent { + public fun (Ljava/lang/String;)V + public final fun getExternalId ()Ljava/lang/String; +} + +public abstract interface class com/onesignal/common/IHostPermissionPrompt { + public abstract fun request (Ljava/lang/String;Lcom/onesignal/common/IHostPermissionPrompt$Callback;)Z +} + +public abstract interface class com/onesignal/common/IHostPermissionPrompt$Callback { + public abstract fun onResult (Z)V +} + +public final class com/onesignal/common/OneSignalWrapper { + public static final field INSTANCE Lcom/onesignal/common/OneSignalWrapper; + public static final fun getHostPermissionPrompt ()Lcom/onesignal/common/IHostPermissionPrompt; + public static final fun getSdkType ()Ljava/lang/String; + public static final fun getSdkVersion ()Ljava/lang/String; + public static final fun setHostPermissionPrompt (Lcom/onesignal/common/IHostPermissionPrompt;)V + public static final fun setSdkType (Ljava/lang/String;)V + public static final fun setSdkVersion (Ljava/lang/String;)V +} + +public final class com/onesignal/core/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public static final field SDK_VERSION Ljava/lang/String; + public fun ()V +} + +public final class com/onesignal/core/activities/PermissionsActivity : androidx/activity/ComponentActivity { + public fun ()V + public fun onRequestPermissionsResult (I[Ljava/lang/String;[I)V +} + +public final class com/onesignal/core/services/SyncJobService : android/app/job/JobService { + public fun ()V + public fun onStartJob (Landroid/app/job/JobParameters;)Z + public fun onStopJob (Landroid/app/job/JobParameters;)Z +} + +public abstract interface class com/onesignal/debug/IDebugManager { + public abstract fun addLogListener (Lcom/onesignal/debug/ILogListener;)V + public abstract fun getAlertLevel ()Lcom/onesignal/debug/LogLevel; + public abstract fun getLogLevel ()Lcom/onesignal/debug/LogLevel; + public abstract fun removeLogListener (Lcom/onesignal/debug/ILogListener;)V + public abstract fun setAlertLevel (Lcom/onesignal/debug/LogLevel;)V + public abstract fun setLogLevel (Lcom/onesignal/debug/LogLevel;)V +} + +public abstract interface class com/onesignal/debug/ILogListener { + public abstract fun onLogEvent (Lcom/onesignal/debug/OneSignalLogEvent;)V +} + +public final class com/onesignal/debug/LogLevel : java/lang/Enum { + public static final field Companion Lcom/onesignal/debug/LogLevel$Companion; + public static final field DEBUG Lcom/onesignal/debug/LogLevel; + public static final field ERROR Lcom/onesignal/debug/LogLevel; + public static final field FATAL Lcom/onesignal/debug/LogLevel; + public static final field INFO Lcom/onesignal/debug/LogLevel; + public static final field NONE Lcom/onesignal/debug/LogLevel; + public static final field VERBOSE Lcom/onesignal/debug/LogLevel; + public static final field WARN Lcom/onesignal/debug/LogLevel; + public static final fun fromInt (I)Lcom/onesignal/debug/LogLevel; + public static final fun fromString (Ljava/lang/String;)Lcom/onesignal/debug/LogLevel; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/onesignal/debug/LogLevel; + public static fun values ()[Lcom/onesignal/debug/LogLevel; +} + +public final class com/onesignal/debug/LogLevel$Companion { + public final fun fromInt (I)Lcom/onesignal/debug/LogLevel; + public final fun fromString (Ljava/lang/String;)Lcom/onesignal/debug/LogLevel; +} + +public final class com/onesignal/debug/OneSignalLogEvent { + public fun (Lcom/onesignal/debug/LogLevel;Ljava/lang/String;)V + public final fun component1 ()Lcom/onesignal/debug/LogLevel; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Lcom/onesignal/debug/LogLevel;Ljava/lang/String;)Lcom/onesignal/debug/OneSignalLogEvent; + public static synthetic fun copy$default (Lcom/onesignal/debug/OneSignalLogEvent;Lcom/onesignal/debug/LogLevel;Ljava/lang/String;ILjava/lang/Object;)Lcom/onesignal/debug/OneSignalLogEvent; + public fun equals (Ljava/lang/Object;)Z + public final fun getEntry ()Ljava/lang/String; + public final fun getLevel ()Lcom/onesignal/debug/LogLevel; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/onesignal/debug/internal/logging/Logging { + public static final field INSTANCE Lcom/onesignal/debug/internal/logging/Logging; + public final fun addListener (Lcom/onesignal/debug/ILogListener;)V + public static final fun atLogLevel (Lcom/onesignal/debug/LogLevel;)Z + public static final fun debug (Ljava/lang/String;Ljava/lang/Throwable;)V + public static synthetic fun debug$default (Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V + public static final fun error (Ljava/lang/String;Ljava/lang/Throwable;)V + public static synthetic fun error$default (Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V + public static final fun fatal (Ljava/lang/String;Ljava/lang/Throwable;)V + public static synthetic fun fatal$default (Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V + public final fun getApplicationService ()Lcom/onesignal/core/internal/application/IApplicationService; + public static final fun getLogLevel ()Lcom/onesignal/debug/LogLevel; + public static final fun getVisualLogLevel ()Lcom/onesignal/debug/LogLevel; + public static final fun info (Ljava/lang/String;Ljava/lang/Throwable;)V + public static synthetic fun info$default (Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V + public static final fun log (Lcom/onesignal/debug/LogLevel;Ljava/lang/String;)V + public static final fun log (Lcom/onesignal/debug/LogLevel;Ljava/lang/String;Ljava/lang/Throwable;)V + public final fun removeListener (Lcom/onesignal/debug/ILogListener;)V + public final fun setApplicationService (Lcom/onesignal/core/internal/application/IApplicationService;)V + public static final fun setLogLevel (Lcom/onesignal/debug/LogLevel;)V + public final fun setLoggerTelemetry (Lcom/onesignal/logger/ILogTelemetryRemote;Lkotlin/jvm/functions/Function1;)V + public static synthetic fun setLoggerTelemetry$default (Lcom/onesignal/debug/internal/logging/Logging;Lcom/onesignal/logger/ILogTelemetryRemote;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static final fun setVisualLogLevel (Lcom/onesignal/debug/LogLevel;)V + public static final fun verbose (Ljava/lang/String;Ljava/lang/Throwable;)V + public static synthetic fun verbose$default (Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V + public static final fun warn (Ljava/lang/String;Ljava/lang/Throwable;)V + public static synthetic fun warn$default (Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V +} + +public abstract interface class com/onesignal/inAppMessages/IInAppMessage { + public abstract fun getMessageId ()Ljava/lang/String; +} + +public abstract interface class com/onesignal/inAppMessages/IInAppMessageClickEvent { + public abstract fun getMessage ()Lcom/onesignal/inAppMessages/IInAppMessage; + public abstract fun getResult ()Lcom/onesignal/inAppMessages/IInAppMessageClickResult; +} + +public abstract interface class com/onesignal/inAppMessages/IInAppMessageClickListener { + public abstract fun onClick (Lcom/onesignal/inAppMessages/IInAppMessageClickEvent;)V +} + +public abstract interface class com/onesignal/inAppMessages/IInAppMessageClickResult { + public abstract fun getActionId ()Ljava/lang/String; + public abstract fun getClosingMessage ()Z + public abstract fun getUrl ()Ljava/lang/String; + public abstract fun getUrlTarget ()Lcom/onesignal/inAppMessages/InAppMessageActionUrlType; +} + +public abstract interface class com/onesignal/inAppMessages/IInAppMessageDidDismissEvent { + public abstract fun getMessage ()Lcom/onesignal/inAppMessages/IInAppMessage; +} + +public abstract interface class com/onesignal/inAppMessages/IInAppMessageDidDisplayEvent { + public abstract fun getMessage ()Lcom/onesignal/inAppMessages/IInAppMessage; +} + +public abstract interface class com/onesignal/inAppMessages/IInAppMessageLifecycleListener { + public abstract fun onDidDismiss (Lcom/onesignal/inAppMessages/IInAppMessageDidDismissEvent;)V + public abstract fun onDidDisplay (Lcom/onesignal/inAppMessages/IInAppMessageDidDisplayEvent;)V + public abstract fun onWillDismiss (Lcom/onesignal/inAppMessages/IInAppMessageWillDismissEvent;)V + public abstract fun onWillDisplay (Lcom/onesignal/inAppMessages/IInAppMessageWillDisplayEvent;)V +} + +public abstract interface class com/onesignal/inAppMessages/IInAppMessageWillDismissEvent { + public abstract fun getMessage ()Lcom/onesignal/inAppMessages/IInAppMessage; +} + +public abstract interface class com/onesignal/inAppMessages/IInAppMessageWillDisplayEvent { + public abstract fun getMessage ()Lcom/onesignal/inAppMessages/IInAppMessage; +} + +public abstract interface class com/onesignal/inAppMessages/IInAppMessagesManager { + public abstract fun addClickListener (Lcom/onesignal/inAppMessages/IInAppMessageClickListener;)V + public abstract fun addLifecycleListener (Lcom/onesignal/inAppMessages/IInAppMessageLifecycleListener;)V + public abstract fun addTrigger (Ljava/lang/String;Ljava/lang/String;)V + public abstract fun addTriggers (Ljava/util/Map;)V + public abstract fun clearTriggers ()V + public abstract fun getPaused ()Z + public abstract fun removeClickListener (Lcom/onesignal/inAppMessages/IInAppMessageClickListener;)V + public abstract fun removeLifecycleListener (Lcom/onesignal/inAppMessages/IInAppMessageLifecycleListener;)V + public abstract fun removeTrigger (Ljava/lang/String;)V + public abstract fun removeTriggers (Ljava/util/Collection;)V + public abstract fun setPaused (Z)V +} + +public final class com/onesignal/inAppMessages/InAppMessageActionUrlType : java/lang/Enum { + public static final field BROWSER Lcom/onesignal/inAppMessages/InAppMessageActionUrlType; + public static final field Companion Lcom/onesignal/inAppMessages/InAppMessageActionUrlType$Companion; + public static final field IN_APP_WEBVIEW Lcom/onesignal/inAppMessages/InAppMessageActionUrlType; + public static final field REPLACE_CONTENT Lcom/onesignal/inAppMessages/InAppMessageActionUrlType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public fun toString ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lcom/onesignal/inAppMessages/InAppMessageActionUrlType; + public static fun values ()[Lcom/onesignal/inAppMessages/InAppMessageActionUrlType; +} + +public final class com/onesignal/inAppMessages/InAppMessageActionUrlType$Companion { + public final fun fromString (Ljava/lang/String;)Lcom/onesignal/inAppMessages/InAppMessageActionUrlType; +} + +public abstract interface class com/onesignal/location/ILocationManager { + public abstract fun isShared ()Z + public abstract fun requestPermission (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setShared (Z)V +} + +public final class com/onesignal/notifications/BackgroundImageLayout { + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getBodyTextColor ()Ljava/lang/String; + public final fun getImage ()Ljava/lang/String; + public final fun getTitleTextColor ()Ljava/lang/String; +} + +public abstract interface class com/onesignal/notifications/IActionButton { + public abstract fun getIcon ()Ljava/lang/String; + public abstract fun getId ()Ljava/lang/String; + public abstract fun getText ()Ljava/lang/String; +} + +public abstract interface class com/onesignal/notifications/IDisplayableMutableNotification : com/onesignal/notifications/IDisplayableNotification, com/onesignal/notifications/IMutableNotification { +} + +public abstract interface class com/onesignal/notifications/IDisplayableNotification : com/onesignal/notifications/INotification { + public abstract fun display ()V +} + +public abstract interface class com/onesignal/notifications/IMutableNotification : com/onesignal/notifications/INotification { + public abstract fun setExtender (Landroidx/core/app/NotificationCompat$Extender;)V +} + +public abstract interface class com/onesignal/notifications/INotification { + public abstract fun getActionButtons ()Ljava/util/List; + public abstract fun getAdditionalData ()Lorg/json/JSONObject; + public abstract fun getAndroidNotificationId ()I + public abstract fun getBackgroundImageLayout ()Lcom/onesignal/notifications/BackgroundImageLayout; + public abstract fun getBigPicture ()Ljava/lang/String; + public abstract fun getBody ()Ljava/lang/String; + public abstract fun getCollapseId ()Ljava/lang/String; + public abstract fun getFromProjectNumber ()Ljava/lang/String; + public abstract fun getGroupKey ()Ljava/lang/String; + public abstract fun getGroupMessage ()Ljava/lang/String; + public abstract fun getGroupedNotifications ()Ljava/util/List; + public abstract fun getLargeIcon ()Ljava/lang/String; + public abstract fun getLaunchURL ()Ljava/lang/String; + public abstract fun getLedColor ()Ljava/lang/String; + public abstract fun getLockScreenVisibility ()I + public abstract fun getNotificationId ()Ljava/lang/String; + public abstract fun getPriority ()I + public abstract fun getRawPayload ()Ljava/lang/String; + public abstract fun getSentTime ()J + public abstract fun getSmallIcon ()Ljava/lang/String; + public abstract fun getSmallIconAccentColor ()Ljava/lang/String; + public abstract fun getSound ()Ljava/lang/String; + public abstract fun getTemplateId ()Ljava/lang/String; + public abstract fun getTemplateName ()Ljava/lang/String; + public abstract fun getTitle ()Ljava/lang/String; + public abstract fun getTtl ()I +} + +public abstract interface class com/onesignal/notifications/INotificationClickEvent { + public abstract fun getNotification ()Lcom/onesignal/notifications/INotification; + public abstract fun getResult ()Lcom/onesignal/notifications/INotificationClickResult; + public abstract fun toJSONObject ()Lorg/json/JSONObject; +} + +public abstract interface class com/onesignal/notifications/INotificationClickListener { + public abstract fun onClick (Lcom/onesignal/notifications/INotificationClickEvent;)V +} + +public abstract interface class com/onesignal/notifications/INotificationClickResult { + public abstract fun getActionId ()Ljava/lang/String; + public abstract fun getUrl ()Ljava/lang/String; +} + +public abstract interface class com/onesignal/notifications/INotificationLifecycleListener { + public abstract fun onWillDisplay (Lcom/onesignal/notifications/INotificationWillDisplayEvent;)V +} + +public abstract interface class com/onesignal/notifications/INotificationReceivedEvent { + public abstract fun getContext ()Landroid/content/Context; + public abstract fun getNotification ()Lcom/onesignal/notifications/IDisplayableMutableNotification; + public abstract fun getRestoring ()Z + public abstract fun preventDefault ()V + public abstract fun preventDefault (Z)V +} + +public final class com/onesignal/notifications/INotificationReceivedEvent$DefaultImpls { + public static fun getRestoring (Lcom/onesignal/notifications/INotificationReceivedEvent;)Z +} + +public abstract interface class com/onesignal/notifications/INotificationServiceExtension { + public abstract fun onNotificationReceived (Lcom/onesignal/notifications/INotificationReceivedEvent;)V +} + +public abstract interface class com/onesignal/notifications/INotificationWillDisplayEvent { + public abstract fun getNotification ()Lcom/onesignal/notifications/IDisplayableNotification; + public abstract fun preventDefault ()V + public abstract fun preventDefault (Z)V +} + +public abstract interface class com/onesignal/notifications/INotificationsManager { + public abstract fun addClickListener (Lcom/onesignal/notifications/INotificationClickListener;)V + public abstract fun addForegroundLifecycleListener (Lcom/onesignal/notifications/INotificationLifecycleListener;)V + public abstract fun addPermissionObserver (Lcom/onesignal/notifications/IPermissionObserver;)V + public abstract fun clearAllNotifications ()V + public abstract fun getCanRequestPermission ()Z + public abstract fun getPermission ()Z + public abstract fun removeClickListener (Lcom/onesignal/notifications/INotificationClickListener;)V + public abstract fun removeForegroundLifecycleListener (Lcom/onesignal/notifications/INotificationLifecycleListener;)V + public abstract fun removeGroupedNotifications (Ljava/lang/String;)V + public abstract fun removeNotification (I)V + public abstract fun removePermissionObserver (Lcom/onesignal/notifications/IPermissionObserver;)V + public abstract fun requestPermission (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/onesignal/notifications/IPermissionObserver { + public abstract fun onNotificationPermissionChange (Z)V +} + +public abstract interface class com/onesignal/session/ISessionManager { + public abstract fun addOutcome (Ljava/lang/String;)V + public abstract fun addOutcomeWithValue (Ljava/lang/String;F)V + public abstract fun addUniqueOutcome (Ljava/lang/String;)V +} + +public abstract interface class com/onesignal/user/IUserManager { + public abstract fun addAlias (Ljava/lang/String;Ljava/lang/String;)V + public abstract fun addAliases (Ljava/util/Map;)V + public abstract fun addEmail (Ljava/lang/String;)V + public abstract fun addObserver (Lcom/onesignal/user/state/IUserStateObserver;)V + public abstract fun addSms (Ljava/lang/String;)V + public abstract fun addTag (Ljava/lang/String;Ljava/lang/String;)V + public abstract fun addTags (Ljava/util/Map;)V + public abstract fun getExternalId ()Ljava/lang/String; + public abstract fun getOnesignalId ()Ljava/lang/String; + public abstract fun getPushSubscription ()Lcom/onesignal/user/subscriptions/IPushSubscription; + public abstract fun getTags ()Ljava/util/Map; + public abstract fun removeAlias (Ljava/lang/String;)V + public abstract fun removeAliases (Ljava/util/Collection;)V + public abstract fun removeEmail (Ljava/lang/String;)V + public abstract fun removeObserver (Lcom/onesignal/user/state/IUserStateObserver;)V + public abstract fun removeSms (Ljava/lang/String;)V + public abstract fun removeTag (Ljava/lang/String;)V + public abstract fun removeTags (Ljava/util/Collection;)V + public abstract fun setLanguage (Ljava/lang/String;)V + public abstract fun trackEvent (Ljava/lang/String;Ljava/util/Map;)V +} + +public final class com/onesignal/user/IUserManager$DefaultImpls { + public static synthetic fun trackEvent$default (Lcom/onesignal/user/IUserManager;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V +} + +public abstract interface class com/onesignal/user/state/IUserStateObserver { + public abstract fun onUserStateChange (Lcom/onesignal/user/state/UserChangedState;)V +} + +public final class com/onesignal/user/state/UserChangedState { + public fun (Lcom/onesignal/user/state/UserState;)V + public final fun getCurrent ()Lcom/onesignal/user/state/UserState; + public final fun toJSONObject ()Lorg/json/JSONObject; +} + +public final class com/onesignal/user/state/UserState { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun getExternalId ()Ljava/lang/String; + public final fun getOnesignalId ()Ljava/lang/String; + public final fun toJSONObject ()Lorg/json/JSONObject; +} + +public abstract interface class com/onesignal/user/subscriptions/IEmailSubscription : com/onesignal/user/subscriptions/ISubscription { + public abstract fun getEmail ()Ljava/lang/String; +} + +public abstract interface class com/onesignal/user/subscriptions/IPushSubscription : com/onesignal/user/subscriptions/ISubscription { + public abstract fun addObserver (Lcom/onesignal/user/subscriptions/IPushSubscriptionObserver;)V + public abstract fun getOptedIn ()Z + public abstract fun getToken ()Ljava/lang/String; + public abstract fun optIn ()V + public abstract fun optOut ()V + public abstract fun removeObserver (Lcom/onesignal/user/subscriptions/IPushSubscriptionObserver;)V +} + +public abstract interface class com/onesignal/user/subscriptions/IPushSubscriptionObserver { + public abstract fun onPushSubscriptionChange (Lcom/onesignal/user/subscriptions/PushSubscriptionChangedState;)V +} + +public abstract interface class com/onesignal/user/subscriptions/ISmsSubscription : com/onesignal/user/subscriptions/ISubscription { + public abstract fun getNumber ()Ljava/lang/String; +} + +public abstract interface class com/onesignal/user/subscriptions/ISubscription { + public abstract fun getId ()Ljava/lang/String; +} + +public final class com/onesignal/user/subscriptions/PushSubscriptionChangedState { + public fun (Lcom/onesignal/user/subscriptions/PushSubscriptionState;Lcom/onesignal/user/subscriptions/PushSubscriptionState;)V + public final fun getCurrent ()Lcom/onesignal/user/subscriptions/PushSubscriptionState; + public final fun getPrevious ()Lcom/onesignal/user/subscriptions/PushSubscriptionState; + public final fun toJSONObject ()Lorg/json/JSONObject; +} + +public final class com/onesignal/user/subscriptions/PushSubscriptionState { + public fun (Ljava/lang/String;Ljava/lang/String;Z)V + public final fun getId ()Ljava/lang/String; + public final fun getOptedIn ()Z + public final fun getToken ()Ljava/lang/String; + public final fun toJSONObject ()Lorg/json/JSONObject; +} + diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/OneSignalWrapper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/OneSignalWrapper.kt index 80e603fa7c..45a8a0fad8 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/OneSignalWrapper.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/OneSignalWrapper.kt @@ -1,5 +1,6 @@ package com.onesignal.common +// Wrapper SDKs call this from Java, C# bindings, and Unity JNI by name. Tracked by apiCheck. object OneSignalWrapper { /** * The type of the wrapper SDK. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt index 9baba18a93..6d7a071bcb 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt @@ -16,6 +16,8 @@ import java.io.PrintWriter import java.io.StringWriter import java.util.concurrent.CopyOnWriteArraySet +// Wrapper SDKs (Flutter, React Native, Cordova, Capacitor) call this directly despite the +// package name, so it is tracked by apiCheck and must stay binary-compatible. object Logging { private const val TAG = "OneSignal" diff --git a/OneSignalSDK/onesignal/in-app-messages/api/in-app-messages.api b/OneSignalSDK/onesignal/in-app-messages/api/in-app-messages.api new file mode 100644 index 0000000000..e69de29bb2 diff --git a/OneSignalSDK/onesignal/location/api/location.api b/OneSignalSDK/onesignal/location/api/location.api new file mode 100644 index 0000000000..e69de29bb2 diff --git a/OneSignalSDK/onesignal/notifications/api/notifications.api b/OneSignalSDK/onesignal/notifications/api/notifications.api new file mode 100644 index 0000000000..368fb3d1a7 --- /dev/null +++ b/OneSignalSDK/onesignal/notifications/api/notifications.api @@ -0,0 +1,74 @@ +public final class com/onesignal/NotificationOpenedActivityHMS : android/app/Activity, com/onesignal/core/internal/application/OneSignalInternalActivity { + public fun ()V +} + +public final class com/onesignal/notifications/activities/NotificationOpenedActivity : com/onesignal/notifications/activities/NotificationOpenedActivityBase { + public fun ()V +} + +public final class com/onesignal/notifications/activities/NotificationOpenedActivityAndroid22AndOlder : com/onesignal/notifications/activities/NotificationOpenedActivityBase { + public fun ()V +} + +public abstract class com/onesignal/notifications/activities/NotificationOpenedActivityBase : android/app/Activity, com/onesignal/core/internal/application/OneSignalInternalActivity { + public fun ()V + protected fun onCreate (Landroid/os/Bundle;)V + protected fun onNewIntent (Landroid/content/Intent;)V +} + +public final class com/onesignal/notifications/bridges/OneSignalHmsEventBridge { + public static final field HMS_SENT_TIME_KEY Ljava/lang/String; + public static final field HMS_TTL_KEY Ljava/lang/String; + public static final field INSTANCE Lcom/onesignal/notifications/bridges/OneSignalHmsEventBridge; + public final fun onMessageReceived (Landroid/content/Context;Lcom/huawei/hms/push/RemoteMessage;)V + public final fun onNewToken (Landroid/content/Context;Ljava/lang/String;)V + public final fun onNewToken (Landroid/content/Context;Ljava/lang/String;Landroid/os/Bundle;)V +} + +public final class com/onesignal/notifications/receivers/ADMMessageReceiver : com/amazon/device/messaging/ADMMessageReceiver { + public static final field Companion Lcom/onesignal/notifications/receivers/ADMMessageReceiver$Companion; + public fun ()V +} + +public final class com/onesignal/notifications/receivers/ADMMessageReceiver$Companion { +} + +public final class com/onesignal/notifications/receivers/BootUpReceiver : android/content/BroadcastReceiver { + public fun ()V + public fun onReceive (Landroid/content/Context;Landroid/content/Intent;)V +} + +public final class com/onesignal/notifications/receivers/FCMBroadcastReceiver : android/content/BroadcastReceiver { + public static final field Companion Lcom/onesignal/notifications/receivers/FCMBroadcastReceiver$Companion; + public fun ()V + public fun onReceive (Landroid/content/Context;Landroid/content/Intent;)V +} + +public final class com/onesignal/notifications/receivers/FCMBroadcastReceiver$Companion { +} + +public final class com/onesignal/notifications/receivers/NotificationDismissReceiver : android/content/BroadcastReceiver { + public fun ()V + public fun onReceive (Landroid/content/Context;Landroid/content/Intent;)V +} + +public final class com/onesignal/notifications/receivers/UpgradeReceiver : android/content/BroadcastReceiver { + public fun ()V + public fun onReceive (Landroid/content/Context;Landroid/content/Intent;)V +} + +public final class com/onesignal/notifications/services/ADMMessageHandler : com/amazon/device/messaging/ADMMessageHandlerBase { + public fun ()V +} + +public final class com/onesignal/notifications/services/ADMMessageHandlerJob : com/amazon/device/messaging/ADMMessageHandlerJobBase { + public fun ()V +} + +public final class com/onesignal/notifications/services/HmsMessageServiceOneSignal : com/huawei/hms/push/HmsMessageService { + public fun ()V + public fun onMessageReceived (Lcom/huawei/hms/push/RemoteMessage;)V + public fun onNewToken (Ljava/lang/String;)V + public fun onNewToken (Ljava/lang/String;Landroid/os/Bundle;)V +} + From 64b9270b6aaf46ea00a00a36242ad848c0c8c31b Mon Sep 17 00:00:00 2001 From: abdulraqeeb33 Date: Mon, 12 Oct 2026 14:21:29 -0500 Subject: [PATCH 03/20] feat: [SDK-5024] add composite login(externalId, profile) as one Create User POST (#2743) Co-authored-by: AR Abdul Azeez Co-authored-by: Cursor --- .../src/main/java/com/onesignal/IOneSignal.kt | 10 + .../src/main/java/com/onesignal/OneSignal.kt | 12 + .../main/java/com/onesignal/OneSignalError.kt | 299 +++++++++ .../java/com/onesignal/OneSignalResult.kt | 136 ++++ .../java/com/onesignal/OneSignalResultData.kt | 111 ++++ .../com/onesignal/OneSignalUserProfile.kt | 83 +++ .../core/internal/config/ConfigModel.kt | 18 +- .../internal/operations/IOperationExecutor.kt | 3 + .../internal/operations/IOperationRepo.kt | 19 +- .../internal/operations/OperationMetadata.kt | 10 + .../internal/operations/impl/OperationRepo.kt | 144 +++- .../impl/OperationRepoIvExtensions.kt | 8 +- .../impl/OperationWaitResultExtensions.kt | 7 + .../com/onesignal/internal/OneSignalImp.kt | 58 +- .../onesignal/user/internal/LoginHelper.kt | 91 ++- .../internal/backend/IUserBackendService.kt | 2 +- .../backend/impl/UserBackendService.kt | 6 +- .../internal/operations/LoginUserOperation.kt | 93 ++- .../impl/executors/LoginProfileApplier.kt | 117 ++++ .../executors/LoginUserOperationExecutor.kt | 164 ++++- .../java/com/onesignal/OneSignalErrorTests.kt | 66 ++ .../com/onesignal/OneSignalResultTests.kt | 616 ++++++++++++++++++ .../OneSignalSuspendMethodsExistTest.kt | 24 + .../onesignal/OneSignalUserProfileTests.kt | 101 +++ .../application/SDKInitSuspendTests.kt | 14 + .../internal/operations/OperationRepoTests.kt | 352 +++++++++- .../user/internal/LoginHelperTests.kt | 344 +++++++++- .../backend/UserBackendServiceTests.kt | 34 +- .../LoginUserOperationExecutorTests.kt | 592 ++++++++++++++++- .../data/model/CompositeLoginExample.java | 27 + .../data/repository/OneSignalRepository.kt | 14 + .../example/ui/components/Dialogs.kt | 96 +++ .../onesignal/example/ui/main/MainScreen.kt | 3 + .../example/ui/main/MainViewModel.kt | 32 + .../com/onesignal/example/ui/main/Sections.kt | 20 + 35 files changed, 3588 insertions(+), 138 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalError.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResult.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResultData.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/OperationMetadata.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationWaitResultExtensions.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalErrorTests.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalResultTests.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalUserProfileTests.kt create mode 100644 examples/demo/app/src/main/java/com/onesignal/example/data/model/CompositeLoginExample.java diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt index 6b38326c82..3f8304f3ab 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt @@ -250,6 +250,16 @@ interface IOneSignal { jwtBearerToken: String? = null, ) + /** + * Login with [externalId] plus [profile]. Email/SMS create or transfer that address; an alias owned elsewhere fails the request. + * Create User upsert (anonymous is not merged). Same id is a no-op once a backend ID exists. Failure still leaves this identity local. + */ + suspend fun login( + externalId: String, + profile: OneSignalUserProfile, + jwtBearerToken: String? = null, + ): OneSignalResult + /** * Logout the current user (suspend version). */ diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt index ed049b5506..45de45443a 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt @@ -424,6 +424,18 @@ object OneSignal { oneSignal.loginSuspend(externalId, jwtBearerToken) } + /** + * Login with [externalId] plus [profile]. Email/SMS create or transfer that address; an alias owned elsewhere fails the request. + * Create User upsert (anonymous is not merged). Same id is a no-op once a backend ID exists. Failure still leaves this identity local. + */ + @JvmStatic + @JvmOverloads + suspend fun login( + externalId: String, + profile: OneSignalUserProfile, + jwtBearerToken: String? = null, + ): OneSignalResult = oneSignal.login(externalId, profile, jwtBearerToken) + /** * Logout the current user without blocking the calling thread. * Suspends until the SDK is initialized if initialization is in progress. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalError.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalError.kt new file mode 100644 index 0000000000..334973f410 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalError.kt @@ -0,0 +1,299 @@ +package com.onesignal + +import com.onesignal.common.toList +import com.onesignal.common.toMap +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.json.JSONArray +import org.json.JSONObject +import java.util.Collections +import java.util.LinkedHashMap + +/** Whether the SDK produced a failure locally or OneSignal's backend returned it. */ +enum class ErrorSource { + CLIENT, + BACKEND, +} + +/** + * The catalog of failure codes shared by every OneSignal SDK. + * + * An enum rather than a sealed hierarchy so that Java callers get a native `switch` and the + * wrapper bridges get a trivial name-to-string marshal. The backend half of the catalog is + * deliberately *not* modelled here — see [BACKEND_ERROR]. + */ +enum class ErrorCode(val source: ErrorSource) { + /** [IOneSignal.initWithContextSuspend] has not been called. */ + NOT_INITIALIZED(ErrorSource.CLIENT), + + /** + * Device storage was locked, so the SDK could not read or write its own preferences. + * Transient: the same call generally succeeds once the device is unlocked. + */ + STORAGE_LOCKED(ErrorSource.CLIENT), + + /** A caller-supplied argument failed validation before any request was made. */ + INVALID_ARGUMENT(ErrorSource.CLIENT), + + /** OneSignal rejected the request. The catalog code is on [OneSignalError.Detail.backendCode]. */ + BACKEND_ERROR(ErrorSource.BACKEND), + + /** No more specific code applies. Callers should surface [OneSignalError.Detail.message]. */ + UNKNOWN(ErrorSource.CLIENT), +} + +/** + * Describes why a OneSignal call failed. + * + * One request can fail for several reasons at once, so [error] is a list of [Detail]. Everything + * the SDK raises locally has exactly one reason, which [first] reads without the indexing + * ceremony. + * + * On the wire this is the list itself, sitting under the envelope's `error` key: + * + * ```json + * { "success": false, "data": null, + * "error": [ { "code": "STORAGE_LOCKED", "source": "CLIENT", "backendCode": null, "message": "..." } ] } + * ``` + * + * The constructor is private on purpose. An `internal` constructor still emits as JVM-public, so + * Java outside this module could build an error with no reasons and leave [first] throwing; private + * closes that hole. Callers construct through [of] or [fromWire]. + */ +class OneSignalError private constructor( + error: List, + /** + * The throwable behind the failure, when there was one. + * + * Deliberately absent from [toList]: a stack trace cannot cross the wrapper bridges, and the + * wire schema has to stay identical across every SDK. This exists so that native Kotlin and + * Java callers do not lose the stack when the suspend APIs report a failure instead of + * throwing it. + */ + val cause: Throwable?, +) { + /** + * Why the call failed. Never empty. + * + * Copied so a caller holding the original list cannot empty it afterwards, and unmodifiable so + * the copy itself cannot be emptied either. Java sees a plain `List` and `clear()` is one + * keystroke away from `get()`; both would leave [first] throwing. + */ + val error: List = Collections.unmodifiableList(error.toList()) + + init { + // [first] is documented as always safe to read, and the wire projection of an empty error + // would claim failure while explaining nothing. Both factories guard this; the check is + // here so a future caller of the constructor cannot quietly break the invariant. + require(this.error.isNotEmpty()) { "OneSignalError requires at least one Detail." } + } + + /** + * A single reason a call failed. + * + * Nested rather than top-level so the name cannot collide with `kotlin.Error`, which is + * auto-imported everywhere, or shadow `java.lang.Error` in a Java file that imports it. + */ + class Detail private constructor( + /** A stable code, safe to branch on. Never localized. */ + val code: ErrorCode, + /** Backend catalog code (e.g. `user-1`). Present only for BACKEND_ERROR. */ + val backendCode: String?, + /** HTTP status of the backend response, when there was one. */ + val httpStatus: Int?, + /** A human-readable description intended for logs and diagnostics, not for end users. */ + val message: String?, + /** Who the failure came from. Kept on UNKNOWN so a backend failure is not re-attributed as client. */ + val source: ErrorSource, + /** Backend `meta` object, when present (e.g. `conflicting_aliases`). */ + val meta: Map?, + ) { + /** Projects this reason onto the cross-SDK wire shape consumed by the wrapper bridges. */ + fun toMap(): Map = + mapOf( + KEY_CODE to code.name, + KEY_SOURCE to source.name, + KEY_BACKEND_CODE to backendCode, + KEY_HTTP_STATUS to httpStatus, + KEY_MESSAGE to message, + KEY_META to meta, + ) + + override fun toString(): String = + "Detail(code=$code, source=$source, backendCode=$backendCode, httpStatus=$httpStatus, message=$message, meta=$meta)" + + internal companion object { + // Private because `const val` in an internal companion still compiles to a public + // static field, which would leak the wire keys into the customer-facing API surface. + private const val KEY_CODE = "code" + private const val KEY_SOURCE = "source" + private const val KEY_BACKEND_CODE = "backendCode" + private const val KEY_HTTP_STATUS = "httpStatus" + private const val KEY_MESSAGE = "message" + private const val KEY_META = "meta" + + /** Rebuilds a reason from its wire shape. Unrecognized codes become [ErrorCode.UNKNOWN]. */ + fun fromMap(map: Map<*, *>): Detail { + val code = codeOf(map[KEY_CODE] as? String) + return Detail( + code = code, + backendCode = optionalString(map[KEY_BACKEND_CODE]), + httpStatus = (map[KEY_HTTP_STATUS] as? Number)?.toInt(), + message = map[KEY_MESSAGE] as? String, + source = sourceOf(map[KEY_SOURCE] as? String) ?: code.source, + meta = optionalMeta(map[KEY_META]), + ) + } + + fun of( + code: ErrorCode, + backendCode: String? = null, + httpStatus: Int? = null, + message: String? = null, + source: ErrorSource = code.source, + meta: Map? = null, + ): Detail = + Detail( + code, + backendCode, + httpStatus, + message, + source, + meta?.let { Collections.unmodifiableMap(LinkedHashMap(it)) }, + ) + + private fun codeOf(name: String?): ErrorCode = ErrorCode.entries.firstOrNull { it.name == name } ?: ErrorCode.UNKNOWN + + private fun sourceOf(name: String?): ErrorSource? = ErrorSource.entries.firstOrNull { it.name == name } + + private fun optionalString(value: Any?): String? = (value as? String)?.takeIf { it.isNotEmpty() } + + private fun optionalMeta(value: Any?): Map? { + val map = + when (value) { + is Map<*, *> -> + value.entries.mapNotNull { (key, entry) -> + (key as? String)?.let { it to entry } + }.toMap() + is JSONObject -> value.toMap() + else -> return null + } + return map.takeIf { it.isNotEmpty() }?.let { Collections.unmodifiableMap(LinkedHashMap(it)) } + } + } + } + + /** The first reason, which is the only one for every failure the SDK raises locally. */ + val first: Detail + get() = error.first() + + /** Projects this error onto the cross-SDK wire shape consumed by the wrapper bridges. */ + fun toList(): List> = error.map { it.toMap() } + + override fun toString(): String = "OneSignalError(error=$error)" + + internal companion object { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = false + } + + /** Builds a single-reason error, which is the shape of everything the SDK raises locally. */ + fun of( + code: ErrorCode, + message: String? = null, + backendCode: String? = null, + httpStatus: Int? = null, + meta: Map? = null, + cause: Throwable? = null, + ): OneSignalError = OneSignalError(listOf(Detail.of(code, backendCode, httpStatus, message, meta = meta)), cause) + + /** Builds a multi-reason error. [reasons] must not be empty. */ + fun of( + reasons: List, + cause: Throwable? = null, + ): OneSignalError = OneSignalError(reasons, cause) + + /** Parses a backend `errors[]` body. Unreadable bodies become one reason with [httpStatus]. */ + fun fromBackendResponse( + httpStatus: Int?, + body: String?, + fallbackMessage: String? = null, + ): OneSignalError { + parseBackendErrors(httpStatus, body)?.let { return OneSignalError(it, cause = null) } + val message = body?.takeIf { it.isNotBlank() } ?: fallbackMessage + return of(ErrorCode.BACKEND_ERROR, message = message, httpStatus = httpStatus) + } + + /** Rebuilds an error from its wire shape. Unreadable entries become UNKNOWN rather than dropping the failure. */ + fun fromWire(raw: Any?): OneSignalError { + val reasons = + when (raw) { + is List<*> -> raw.map { reasonOf(it) } + // org.json is what a bridge naturally parses with, and JSONArray is not a + // java.util.List. Convert rather than treating the whole array as one reason. + is JSONArray -> raw.toList().orEmpty().map { reasonOf(it) } + else -> listOf(reasonOf(raw)) + } + return OneSignalError(reasons.ifEmpty { listOf(Detail.of(ErrorCode.UNKNOWN)) }, cause = null) + } + + private fun reasonOf(raw: Any?): Detail = + when (raw) { + is Map<*, *> -> Detail.fromMap(raw) + is JSONObject -> Detail.fromMap(raw.toMap()) + else -> Detail.of(ErrorCode.UNKNOWN, message = raw?.toString()) + } + + private fun parseBackendErrors( + httpStatus: Int?, + body: String?, + ): List? { + if (body.isNullOrBlank()) return null + return try { + val errors = (json.parseToJsonElement(body) as? JsonObject)?.get("errors") as? JsonArray + val details = + errors?.mapNotNull { element -> + val item = element as? JsonObject ?: return@mapNotNull null + Detail.of( + code = ErrorCode.BACKEND_ERROR, + backendCode = item.optionalString("code"), + httpStatus = httpStatus, + message = item.optionalString("title"), + meta = (item["meta"] as? JsonObject)?.toPlainMap()?.takeIf { it.isNotEmpty() }, + ) + } + details?.takeIf { it.isNotEmpty() } + } catch (_: Exception) { + null + } + } + + private fun JsonObject.optionalString(key: String): String? = + (this[key] as? JsonPrimitive) + ?.takeIf { it !is JsonNull } + ?.content + ?.takeIf { it.isNotEmpty() } + + private fun JsonObject.toPlainMap(): Map = entries.associate { it.key to it.value.toPlain() } + + private fun JsonElement.toPlain(): Any? = + when (this) { + is JsonNull -> null + is JsonPrimitive -> + when { + isString -> content + content.equals("true", ignoreCase = true) -> true + content.equals("false", ignoreCase = true) -> false + else -> content.toLongOrNull() ?: content.toDoubleOrNull() ?: content + } + is JsonObject -> toPlainMap() + is JsonArray -> map { it.toPlain() } + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResult.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResult.kt new file mode 100644 index 0000000000..120a95010e --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResult.kt @@ -0,0 +1,136 @@ +package com.onesignal + +import com.onesignal.common.toMap +import org.json.JSONObject + +/** + * The outcome of an asynchronous OneSignal call: either a [data] payload or an [error], never both + * and never neither. + * + * Every SDK returns the same envelope, so a wrapper can handle results uniformly regardless of the + * platform underneath it: + * + * ```json + * { "success": true, "data": { }, "error": null } + * { "success": false, "data": null, "error": [ { "code": "STORAGE_LOCKED", ... } ] } + * ``` + * + * The presence of [error] is what defines the outcome; [isSuccess] and the wire-level `success` + * flag are both derived from it, so the two can never disagree. + * + * The constructor is private on purpose. An `internal` constructor still emits as JVM-public, so + * Java outside this module could build an envelope that violates the either/or invariant; private + * closes that hole. Callers construct through [success], [failure], or [fromMap]. + * + * From Kotlin: + * ```kotlin + * val result = OneSignal.login("user-123") + * if (result.isSuccess) println(result.data?.onesignalId) else println(result.error?.first?.code) + * ``` + * + * From Java the generated accessors read naturally: + * ```java + * if (result.isSuccess()) { result.getData(); } else { result.getError(); } + * ``` + */ +class OneSignalResult private constructor( + /** The payload on success, `null` on failure. */ + val data: T?, + /** The failure detail on failure, `null` on success. */ + val error: OneSignalError?, +) { + init { + // [isSuccess] reads error while callers read data, so neither or both leaves the two disagreeing. + require((data == null) != (error == null)) { "OneSignalResult carries exactly one of data or error." } + } + + /** `true` when the call completed successfully. Equivalent to `error == null`. */ + val isSuccess: Boolean + get() = error == null + + /** Projects the envelope onto the cross-SDK wire shape consumed by the wrapper bridges. */ + fun toMap(): Map = + mapOf( + KEY_SUCCESS to isSuccess, + KEY_DATA to data?.toMap(), + KEY_ERROR to error?.toList(), + ) + + override fun toString(): String = if (isSuccess) "OneSignalResult(success, data=$data)" else "OneSignalResult(failure, error=$error)" + + internal companion object { + // Private because `const val` in an internal companion still compiles to a public static + // field, which would leak the wire keys into the customer-facing API surface. + private const val KEY_SUCCESS = "success" + private const val KEY_DATA = "data" + private const val KEY_ERROR = "error" + + fun success(data: T): OneSignalResult = OneSignalResult(data, null) + + fun failure(error: OneSignalError): OneSignalResult = OneSignalResult(null, error) + + fun failure( + code: ErrorCode, + message: String? = null, + backendCode: String? = null, + httpStatus: Int? = null, + cause: Throwable? = null, + ): OneSignalResult = failure(OneSignalError.of(code, message, backendCode, httpStatus, cause = cause)) + + /** + * Rebuilds an envelope from its wire shape, delegating payload parsing to [dataParser]. + * + * The incoming `success` flag is deliberately ignored: [error] is the single source of + * truth, which keeps a malformed producer from yielding a result that claims success while + * carrying an error. Unrecognized keys are ignored so a newer producer can add fields + * without breaking an older consumer. + * + * Neither `error` nor `data` is read with a cast that doubles as its own presence check. + * Such a cast reports a value the parser could not type as a value that was never sent, + * which turns a failure into an empty success and an unreadable payload into a blank one. + * + * A payload that is present but unreadable (neither a [Map] nor a [JSONObject]) is reported + * as a failure. That contradicts the producer, which was reporting success, and it is still + * the lesser harm: the envelope's whole purpose is that [isSuccess] tells a caller whether + * [data] can be trusted, and a fabricated payload is indistinguishable at the call site from + * one the producer really sent. A caller handed a failure retries or reports it; a caller + * handed a blank payload logs someone in as nobody. + */ + fun fromMap( + map: Map, + dataParser: (Map<*, *>) -> T?, + ): OneSignalResult { + val reasons = map[KEY_ERROR] + // JSONObject.NULL is org.json's JSON null, a live object, so `!= null` would treat a + // successful envelope's `"error": null` as a present error. + if (!reasons.isJsonAbsent()) { + return failure(OneSignalError.fromWire(reasons)) + } + + val payload = map[KEY_DATA] + return when { + payload.isJsonAbsent() -> parsed(dataParser, emptyMap()) + payload is Map<*, *> -> parsed(dataParser, payload) + payload is JSONObject -> parsed(dataParser, payload.toMap()) + else -> failure(ErrorCode.UNKNOWN, unreadablePayload(payload)) + } + } + + private fun parsed( + dataParser: (Map<*, *>) -> T?, + payload: Map<*, *>, + ): OneSignalResult { + val data = dataParser(payload) ?: return failure(ErrorCode.UNKNOWN, MISSING_REQUIRED_FIELDS) + return success(data) + } + + // The type is enough to diagnose the producer. The payload itself is identity data, and an + // error message is somewhere it would be logged. + private fun unreadablePayload(payload: Any?): String = + "Result data could not be read: expected a map but received ${payload?.let { it::class.simpleName }}." + + private const val MISSING_REQUIRED_FIELDS = "Result data was missing required fields." + + private fun Any?.isJsonAbsent(): Boolean = this === null || this === JSONObject.NULL + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResultData.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResultData.kt new file mode 100644 index 0000000000..17e27f1134 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResultData.kt @@ -0,0 +1,111 @@ +package com.onesignal + +/** + * Implemented by every payload that can travel inside a [OneSignalResult]. + * + * Sealed so a new member is never a silent source-compatible break for external implementors — this + * contract exists so the envelope can project onto the wire shape without knowing which payload it + * is carrying, and is not intended to be implemented outside the SDK. + */ +sealed interface OneSignalResultData { + /** Projects this payload onto the cross-SDK wire shape consumed by the wrapper bridges. */ + fun toMap(): Map +} + +/* + * The payloads themselves. + * + * Several of these start with no fields. That is intentional, and it is why none of them is a + * `data class` or an `object`: a regular class with an internal constructor can gain nullable or + * defaulted fields later without changing any signature, without a singleton blocking per-call + * state, and without silently altering generated equals/hashCode/toString/copy behavior that a + * customer may have come to depend on. + * + * Field names here are also the wire keys, so they are additive-only: never renamed, never removed. + */ + +/** The payload returned by a successful login. */ +class LoginData internal constructor( + /** The OneSignal ID the external ID is now associated with. */ + val onesignalId: String, + /** The external ID that was logged in. */ + val externalId: String, + /** ID of the email subscription for the address passed in, if the backend returned one. */ + val emailSubscriptionId: String? = null, + /** ID of the SMS subscription for the number passed in, if the backend returned one. */ + val smsSubscriptionId: String? = null, +) : OneSignalResultData { + override fun toMap(): Map = + buildMap { + put(KEY_ONESIGNAL_ID, onesignalId) + put(KEY_EXTERNAL_ID, externalId) + emailSubscriptionId?.let { put(KEY_EMAIL_SUBSCRIPTION_ID, it) } + smsSubscriptionId?.let { put(KEY_SMS_SUBSCRIPTION_ID, it) } + } + + override fun toString(): String = + "LoginData(onesignalId=$onesignalId, externalId=$externalId, emailSubscriptionId=$emailSubscriptionId, smsSubscriptionId=$smsSubscriptionId)" + + internal companion object { + // Private because `const val` in an internal companion still compiles to a public static + // field, which would leak the wire keys into the customer-facing API surface. + private const val KEY_ONESIGNAL_ID = "onesignalId" + private const val KEY_EXTERNAL_ID = "externalId" + private const val KEY_EMAIL_SUBSCRIPTION_ID = "emailSubscriptionId" + private const val KEY_SMS_SUBSCRIPTION_ID = "smsSubscriptionId" + + /** Null when [onesignalId] or [externalId] is missing, empty, or not a string. */ + fun fromMap(map: Map<*, *>): LoginData? { + val onesignalId = map[KEY_ONESIGNAL_ID] as? String + val externalId = map[KEY_EXTERNAL_ID] as? String + if (onesignalId.isNullOrEmpty() || externalId.isNullOrEmpty()) return null + return LoginData( + onesignalId, + externalId, + emailSubscriptionId = optionalString(map[KEY_EMAIL_SUBSCRIPTION_ID]), + smsSubscriptionId = optionalString(map[KEY_SMS_SUBSCRIPTION_ID]), + ) + } + + private fun optionalString(value: Any?): String? = (value as? String)?.takeIf { it.isNotEmpty() } + } +} + +/** The payload returned by a successful logout. Carries no fields yet. */ +class LogoutData internal constructor() : OneSignalResultData { + override fun toMap(): Map = emptyMap() + + override fun toString(): String = "LogoutData()" + + internal companion object { + fun fromMap( + @Suppress("UNUSED_PARAMETER") map: Map<*, *>, + ): LogoutData = LogoutData() + } +} + +/** The payload returned by a successful user JWT update. Carries no fields yet. */ +class UpdateUserJwtData internal constructor() : OneSignalResultData { + override fun toMap(): Map = emptyMap() + + override fun toString(): String = "UpdateUserJwtData()" + + internal companion object { + fun fromMap( + @Suppress("UNUSED_PARAMETER") map: Map<*, *>, + ): UpdateUserJwtData = UpdateUserJwtData() + } +} + +/** The payload returned by a successful initialization. Carries no fields yet. */ +class InitData internal constructor() : OneSignalResultData { + override fun toMap(): Map = emptyMap() + + override fun toString(): String = "InitData()" + + internal companion object { + fun fromMap( + @Suppress("UNUSED_PARAMETER") map: Map<*, *>, + ): InitData = InitData() + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt new file mode 100644 index 0000000000..bc9ca88cc3 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt @@ -0,0 +1,83 @@ +package com.onesignal + +import com.onesignal.common.OneSignalUtils + +/** + * Profile applied at login. Email/SMS create or transfer that address onto this user; they do not replace other subscriptions. + */ +class OneSignalUserProfile @JvmOverloads constructor( + email: String? = null, + sms: String? = null, + tags: Map = emptyMap(), + aliases: Map = emptyMap(), +) { + val email: String? = email?.takeIf { it.isNotBlank() } + val sms: String? = sms?.takeIf { it.isNotBlank() } + val tags: Map = tags.toMap() + val aliases: Map = aliases.toMap() + + internal val hasFields: Boolean + get() = + !email.isNullOrBlank() || + !sms.isNullOrBlank() || + tags.isNotEmpty() || + aliases.isNotEmpty() + + internal fun validationError(): String? = + when { + email != null && !OneSignalUtils.isValidEmail(email) -> "Invalid email address" + sms != null && !OneSignalUtils.isValidPhoneNumber(sms) -> "Invalid SMS number" + else -> null + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is OneSignalUserProfile) return false + return email == other.email && + sms == other.sms && + tags == other.tags && + aliases == other.aliases + } + + override fun hashCode(): Int { + var result = email?.hashCode() ?: 0 + result = 31 * result + (sms?.hashCode() ?: 0) + result = 31 * result + tags.hashCode() + result = 31 * result + aliases.hashCode() + return result + } + + override fun toString(): String = + "OneSignalUserProfile(email=${redact(email)}, sms=${redact(sms)}, tags=${tags.size}, aliases=${aliases.size})" + + /** Java builder. Use this instead of the constructor when only some fields are set. */ + class Builder { + private var email: String? = null + private var sms: String? = null + private var tags: Map = emptyMap() + private var aliases: Map = emptyMap() + + /** Email to create or transfer onto this user at login. */ + fun setEmail(email: String?) = apply { this.email = email } + + /** SMS to create or transfer onto this user at login. Must be E.164 (e.g. +14155552671). */ + fun setSms(sms: String?) = apply { this.sms = sms } + + /** Sets tags to apply at login. The map is copied. */ + fun setTags(tags: Map) = apply { this.tags = tags.toMap() } + + /** Sets aliases to apply at login. The map is copied. */ + fun setAliases(aliases: Map) = apply { this.aliases = aliases.toMap() } + + /** Builds the profile. */ + fun build() = OneSignalUserProfile(email, sms, tags, aliases) + } + + companion object { + /** Returns a new Java builder. */ + @JvmStatic + fun builder() = Builder() + + private fun redact(value: String?): String = if (value == null) "null" else "" + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt index 96f6aa7cce..959b29f5a1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt @@ -108,7 +108,7 @@ class ConfigModel : Model() { * The timeout in milliseconds for an HTTP connection GET request. */ var httpGetTimeout: Int - get() = getIntProperty(::httpGetTimeout.name) { 60000 } + get() = getIntProperty(::httpGetTimeout.name) { DEFAULT_TIMEOUT_MS.toInt() } set(value) { setIntProperty(::httpGetTimeout.name, value) } @@ -174,7 +174,7 @@ class ConfigModel : Model() { * to re-create records as a last resort. */ var opRepoPostCreateRetryUpTo: Long - get() = getLongProperty(::opRepoPostCreateRetryUpTo.name) { 60_000 } + get() = getLongProperty(::opRepoPostCreateRetryUpTo.name) { DEFAULT_TIMEOUT_MS } set(value) { setLongProperty(::opRepoPostCreateRetryUpTo.name, value) } @@ -191,6 +191,16 @@ class ConfigModel : Model() { setLongProperty(::opRepoDefaultFailRetryBackoff.name, value) } + /** + * How long [com.onesignal.core.internal.operations.IOperationRepo.enqueueAndAwaitResult] waits. + * The operation stays queued after the waiter is released. + */ + var opRepoAwaitTimeout: Long + get() = getLongProperty(::opRepoAwaitTimeout.name) { DEFAULT_TIMEOUT_MS } + set(value) { + setLongProperty(::opRepoAwaitTimeout.name, value) + } + /** * The minimum number of milliseconds required to pass to allow the fetching of IAM to occur. */ @@ -386,6 +396,10 @@ class ConfigModel : Model() { return null } + + companion object { + const val DEFAULT_TIMEOUT_MS = 60_000L + } } /** diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationExecutor.kt index 4bae598969..5a7c66e1bb 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationExecutor.kt @@ -42,6 +42,9 @@ class ExecutionResponse( * The module handing this should delay any future requests by this time. */ val retryAfterSeconds: Int? = null, + val httpStatusCode: Int? = null, + val httpResponse: String? = null, + val metadata: OperationMetadata? = null, ) enum class ExecutionResult { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt index d2dceea5c3..76de2d78bf 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt @@ -32,7 +32,16 @@ interface IOperationRepo { suspend fun enqueueAndWait( operation: Operation, flush: Boolean = false, - ): Boolean + ): Boolean = enqueueAndAwaitResult(operation, flush).success + + /** + * Same as [enqueueAndWait], plus the HTTP status and body when the executor recorded a backend failure. + * Returns failure if the wait exceeds [com.onesignal.core.internal.config.ConfigModel.opRepoAwaitTimeout]. + */ + suspend fun enqueueAndAwaitResult( + operation: Operation, + flush: Boolean = false, + ): OperationWaitResult /** * Check if the queue contains a specific operation type @@ -44,6 +53,14 @@ interface IOperationRepo { fun forceExecuteOperations() } +/** Result of [IOperationRepo.enqueueAndAwaitResult]. [metadata] is null when unused. */ +class OperationWaitResult( + val success: Boolean, + val httpStatusCode: Int? = null, + val httpResponse: String? = null, + val metadata: OperationMetadata? = null, +) + // Extension function so the syntax containsInstanceOf() can be used over // containsInstanceOf(Operation::class) inline fun IOperationRepo.containsInstanceOf(): Boolean = containsInstanceOf(T::class) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/OperationMetadata.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/OperationMetadata.kt new file mode 100644 index 0000000000..dcf8eab2b9 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/OperationMetadata.kt @@ -0,0 +1,10 @@ +package com.onesignal.core.internal.operations + +/** Executor-specific wait payload. Add a subtype when a new enqueue-and-wait needs extra fields. */ +sealed interface OperationMetadata + +internal data class LoginWaitMetadata( + val onesignalId: String, + val emailSubscriptionId: String? = null, + val smsSubscriptionId: String? = null, +) : OperationMetadata diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt index c1a8db7ba2..37e64cdcc3 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt @@ -5,11 +5,13 @@ import com.onesignal.common.threading.WaiterWithValue import com.onesignal.common.threading.suspendifyOnIO import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.config.impl.IdentityVerificationService +import com.onesignal.core.internal.operations.ExecutionResponse import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.GroupComparisonType import com.onesignal.core.internal.operations.IOperationExecutor import com.onesignal.core.internal.operations.IOperationRepo import com.onesignal.core.internal.operations.Operation +import com.onesignal.core.internal.operations.OperationWaitResult import com.onesignal.core.internal.startup.IStartableService import com.onesignal.core.internal.time.ITime import com.onesignal.debug.LogLevel @@ -40,10 +42,41 @@ internal class OperationRepo( internal class OperationQueueItem( val operation: Operation, - var waiter: WaiterWithValue? = null, // waiter may transfer during operation de-dupe + waiter: WaiterWithValue? = null, val bucket: Int, var retries: Int = 0, ) { + val waiters = mutableListOf>() + + @Volatile + var completedResult: OperationWaitResult? = null + private set + + init { + if (waiter != null) waiters.add(waiter) + } + + fun wakeWaiters(result: OperationWaitResult) { + val toWake: List> + synchronized(this) { + completedResult = result + toWake = waiters.toList() + waiters.clear() + } + toWake.forEach { it.wake(result) } + } + + fun attachOrWake(incoming: List>) { + val completed = + synchronized(this) { + completedResult ?: run { + waiters.addAll(incoming) + return + } + } + incoming.forEach { it.wake(completed) } + } + override fun toString(): String { return "bucket:$bucket, retries:$retries, operation:$operation\n" } @@ -64,6 +97,7 @@ internal class OperationRepo( private val executorsMap: Map internal val queue = mutableListOf() + private val inFlightLogins = mutableMapOf() private val waiter = WaiterWithValue() private val retryWaiter = WaiterWithValue() private var paused = false @@ -145,20 +179,26 @@ internal class OperationRepo( } } - override suspend fun enqueueAndWait( + override suspend fun enqueueAndAwaitResult( operation: Operation, flush: Boolean, - ): Boolean { - if (shouldSuppressAnonymousOp(operation)) return false + ): OperationWaitResult { + if (shouldSuppressAnonymousOp(operation)) return OperationWaitResult(false) Logging.log(LogLevel.DEBUG, "OperationRepo.enqueueAndWait(operation: $operation, force: $flush)") operation.id = UUID.randomUUID().toString() - val waiter = WaiterWithValue() + val waiter = WaiterWithValue() scope.launch { internalEnqueue(OperationQueueItem(operation, waiter, bucket = enqueueIntoBucket), flush, true) } - return waiter.waitForWake() + val timeoutMs = _configModelStore.model.opRepoAwaitTimeout + return withTimeoutOrNull(timeoutMs) { + waiter.waitForWake() + } ?: run { + Logging.warn("OperationRepo.enqueueAndWait timed out after $timeoutMs ms: $operation") + OperationWaitResult(false) + } } /** @@ -203,31 +243,38 @@ internal class OperationRepo( // Dedupe LoginUserOperation by onesignalId. val op = queueItem.operation if (op is LoginUserOperation) { - val existing = + val existingInQueue = queue.firstOrNull { it.operation is LoginUserOperation && it.operation.onesignalId == op.onesignalId } + val existing = existingInQueue ?: inFlightLogins[op.onesignalId] 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.") + if (paused) { + queueItem.waiters.forEach { it.wake(OperationWaitResult(false)) } + if (!addToStore) { + _operationModelStore.remove(queueItem.operation.id) + } + return } - // 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) + // Attach waiters, or wake them if execute already finished (post-create delay). + if (existingInQueue != 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.") + } + existingOp.mergeProfileFrom(op) } + existing.attachOrWake(queueItem.waiters) if (!addToStore) { _operationModelStore.remove(queueItem.operation.id) } @@ -294,7 +341,7 @@ internal class OperationRepo( val removedIds: List = synchronized(queue) { val anonymous = queue.filter { it.operation.externalId == null } - anonymous.forEach { it.waiter?.wake(false) } + anonymous.forEach { it.wakeWaiters(OperationWaitResult(false)) } queue.removeAll(anonymous) // IV=ON never transfers anonymous state; clear existingOnesignalId so the // executor takes the createUser (upsert) path. The merge-anon-into-identified @@ -390,7 +437,7 @@ internal class OperationRepo( ExecutionResult.SUCCESS -> { // on success we remove the operation from the store and wake any waiters ops.forEach { _operationModelStore.remove(it.operation.id) } - ops.forEach { it.waiter?.wake(true) } + ops.forEach { it.wakeWaiters(response.toWaitResult(true)) } } ExecutionResult.FAIL_UNAUTHORIZED -> { // Outer gate: dispatch to IV extension only on new code paths. @@ -401,24 +448,36 @@ internal class OperationRepo( ops, _jwtTokenStore, _identityVerificationService.ivBehaviorActive, + response, ) if (!handled) { // IV inactive or anon op: drop and wake waiters, matching FAIL_NORETRY. Logging.warn("Operation execution failed without retry: $operations") - dropAndWake(ops) + dropAndWake(ops, response) } } ExecutionResult.FAIL_NORETRY, ExecutionResult.FAIL_CONFLICT, -> { Logging.warn("Operation execution failed without retry: $operations") - dropAndWake(ops) + // Login 4xx is terminal for login only. Grouped push create/transfer stay queued + // so a later successful login can translate their local onesignalId. + val followers = ops.filter { it != startingOp } + if (startingOp.operation is LoginUserOperation && followers.isNotEmpty()) { + _operationModelStore.remove(startingOp.operation.id) + startingOp.wakeWaiters(response.toWaitResult(false)) + synchronized(queue) { + followers.reversed().forEach { queue.add(0, it) } + } + } else { + dropAndWake(ops, response) + } } ExecutionResult.SUCCESS_STARTING_ONLY -> { // remove the starting operation from the store and wake any waiters, then // add back all but the starting op to the front of the queue to be re-executed _operationModelStore.remove(startingOp.operation.id) - startingOp.waiter?.wake(true) + startingOp.wakeWaiters(response.toWaitResult(true)) synchronized(queue) { ops.filter { it != startingOp }.reversed().forEach { queue.add(0, it) } } @@ -440,7 +499,7 @@ internal class OperationRepo( // keep the failed operation and pause the operation repo from executing paused = true // Unblock any enqueueAndWait callers so loginSuspend doesn't hang. - ops.forEach { it.waiter?.wake(false) } + ops.forEach { it.wakeWaiters(response.toWaitResult(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. @@ -473,13 +532,23 @@ internal class OperationRepo( } catch (e: Throwable) { Logging.log(LogLevel.ERROR, "Error attempting to execute operation: $ops", e) dropAndWake(ops) + } finally { + synchronized(queue) { + ops.forEach { item -> + val login = item.operation as? LoginUserOperation ?: return@forEach + inFlightLogins.remove(login.onesignalId) + } + } } } - /** Drop ops from the persistent store and wake any waiters with `false` (failure). */ - private fun dropAndWake(ops: List) { + /** Drop ops from the persistent store and wake any waiters with failure. */ + private fun dropAndWake( + ops: List, + response: ExecutionResponse? = null, + ) { ops.forEach { _operationModelStore.remove(it.operation.id) } - ops.forEach { it.waiter?.wake(false) } + ops.forEach { it.wakeWaiters(response.toWaitResult(false)) } } /** @@ -544,7 +613,12 @@ internal class OperationRepo( if (startingOp != null) { queue.remove(startingOp) - getGroupableOperations(startingOp) + val grouped = getGroupableOperations(startingOp) + grouped.forEach { item -> + val login = item.operation as? LoginUserOperation ?: return@forEach + inFlightLogins[login.onesignalId] = item + } + grouped } else { null } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepoIvExtensions.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepoIvExtensions.kt index 34a108fdbc..926bccb0c1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepoIvExtensions.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepoIvExtensions.kt @@ -1,5 +1,6 @@ package com.onesignal.core.internal.operations.impl +import com.onesignal.core.internal.operations.ExecutionResponse import com.onesignal.debug.internal.logging.Logging import com.onesignal.user.internal.jwt.JwtTokenStore @@ -43,9 +44,10 @@ internal fun OperationRepo.handleFailUnauthorized( ops: List, jwtTokenStore: JwtTokenStore, ivBehaviorActive: Boolean, + response: ExecutionResponse, ): Boolean { - if (!ivBehaviorActive) return false - val externalId = startingOp.operation.externalId ?: return false + val externalId = startingOp.operation.externalId + if (!ivBehaviorActive || externalId == null) return false // Schedules an async fire of onUserJwtInvalidated to subscribers via // OneSignalDispatchers.launchOnDefault — the developer-facing listener invocation is @@ -58,7 +60,7 @@ internal fun OperationRepo.handleFailUnauthorized( ) // Wake enqueueAndWait callers; re-queue with waiter = null because the original waiter // is already woken. - ops.forEach { it.waiter?.wake(false) } + ops.forEach { it.wakeWaiters(response.toWaitResult(false)) } synchronized(queue) { ops.reversed().forEach { queue.add(0, OperationRepo.OperationQueueItem(it.operation, waiter = null, bucket = it.bucket, retries = it.retries)) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationWaitResultExtensions.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationWaitResultExtensions.kt new file mode 100644 index 0000000000..eaccc37770 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationWaitResultExtensions.kt @@ -0,0 +1,7 @@ +package com.onesignal.core.internal.operations.impl + +import com.onesignal.core.internal.operations.ExecutionResponse +import com.onesignal.core.internal.operations.OperationWaitResult + +internal fun ExecutionResponse?.toWaitResult(success: Boolean): OperationWaitResult = + OperationWaitResult(success, this?.httpStatusCode, this?.httpResponse, this?.metadata) 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 86b788c7a3..5d81224a2f 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 @@ -2,8 +2,13 @@ package com.onesignal.internal import android.content.Context import android.os.Build +import com.onesignal.ErrorCode import com.onesignal.IOneSignal import com.onesignal.IUserJwtInvalidatedListener +import com.onesignal.LoginData +import com.onesignal.OneSignalError +import com.onesignal.OneSignalResult +import com.onesignal.OneSignalUserProfile import com.onesignal.common.AndroidUtils import com.onesignal.common.DeviceUtils import com.onesignal.common.OneSignalUtils @@ -22,6 +27,7 @@ import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.config.impl.IdentityVerificationService import com.onesignal.core.internal.features.IFeatureManager import com.onesignal.core.internal.operations.IOperationRepo +import com.onesignal.core.internal.operations.OperationWaitResult import com.onesignal.core.internal.preferences.IPreferencesService import com.onesignal.core.internal.preferences.PreferenceStoreFix import com.onesignal.core.internal.startup.StartupService @@ -223,6 +229,7 @@ internal class OneSignalImp : IOneSignal, configModel = configModel, jwtTokenStore = jwtTokenStore, lock = loginLogoutLock, + subscriptionModelStore = subscriptionModelStore, ) } @@ -478,7 +485,7 @@ internal class OneSignalImp : IOneSignal, if (isMissing(externalId, "login: externalId")) return - val context = loginHelper.switchUser(externalId, jwtBearerToken) ?: return + val context = loginHelper.switchUser(externalId, jwtBearerToken).context ?: return suspendifyOnIO { loginHelper.enqueueLogin(context) } } @@ -813,10 +820,57 @@ internal class OneSignalImp : IOneSignal, if (isMissing(externalId, "login: externalId")) return@withContext - val context = loginHelper.switchUser(externalId, jwtBearerToken) ?: return@withContext + val context = loginHelper.switchUser(externalId, jwtBearerToken).context ?: return@withContext loginHelper.enqueueLogin(context) } + override suspend fun login( + externalId: String, + profile: OneSignalUserProfile, + jwtBearerToken: String?, + ): OneSignalResult = + withContext(ioDispatcher) { + Logging.log(LogLevel.DEBUG, "login(externalId: $externalId, profile: $profile, jwtBearerToken: ...${jwtBearerToken?.takeLast(8)})") + + profile.validationError()?.let { + return@withContext OneSignalResult.failure(ErrorCode.INVALID_ARGUMENT, it) + } + + suspendUntilInit(operationName = "login") + + if (isMissing(externalId, "login: externalId")) { + return@withContext OneSignalResult.failure( + ErrorCode.INVALID_ARGUMENT, + "login: externalId is required", + ) + } + + val switched = loginHelper.switchUser(externalId, jwtBearerToken, profile) + if (switched.context != null) { + val completed = loginHelper.enqueueLogin(switched.context, profile) + if (!completed.success) { + val error = + if (completed.httpStatusCode != null) { + OneSignalError.fromBackendResponse( + httpStatus = completed.httpStatusCode, + body = completed.httpResponse, + fallbackMessage = loginFailureMessage(completed), + ) + } else { + OneSignalError.of(ErrorCode.UNKNOWN, loginFailureMessage(completed)) + } + return@withContext OneSignalResult.failure(error) + } + return@withContext OneSignalResult.success( + loginHelper.loginData(externalId, profile, completed, switched.onesignalId), + ) + } + OneSignalResult.success(loginHelper.loginData(externalId, profile, fallbackOnesignalId = switched.onesignalId)) + } + + private fun loginFailureMessage(wait: OperationWaitResult): String = + wait.httpStatusCode?.let { "Login did not complete (HTTP $it)." } ?: "Login did not complete." + override suspend fun updateUserJwtSuspend( externalId: String, token: String, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt index 09984b3140..762ea9135a 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt @@ -1,12 +1,21 @@ package com.onesignal.user.internal +import com.onesignal.LoginData +import com.onesignal.OneSignalUserProfile +import com.onesignal.common.IDManager +import com.onesignal.common.PIIHasher import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.operations.IOperationRepo +import com.onesignal.core.internal.operations.LoginWaitMetadata +import com.onesignal.core.internal.operations.OperationWaitResult import com.onesignal.debug.internal.logging.Logging import com.onesignal.user.internal.identity.IdentityModelStore import com.onesignal.user.internal.jwt.JwtRequirement import com.onesignal.user.internal.jwt.JwtTokenStore import com.onesignal.user.internal.operations.LoginUserOperation +import com.onesignal.user.internal.subscriptions.SubscriptionModel +import com.onesignal.user.internal.subscriptions.SubscriptionModelStore +import com.onesignal.user.internal.subscriptions.SubscriptionType internal class LoginHelper( private val identityModelStore: IdentityModelStore, @@ -15,6 +24,7 @@ internal class LoginHelper( private val configModel: ConfigModel, private val jwtTokenStore: JwtTokenStore, private val lock: Any, + private val subscriptionModelStore: SubscriptionModelStore, ) { internal data class LoginEnqueueContext( val appId: String, @@ -23,16 +33,19 @@ internal class LoginHelper( val existingOneSignalId: String?, ) + internal data class LoginSwitchResult( + val context: LoginEnqueueContext?, + val onesignalId: 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). + * Switch local identity under the login lock. Null [LoginSwitchResult.context] means do not enqueue. */ internal fun switchUser( externalId: String, jwtBearerToken: String? = null, - ): LoginEnqueueContext? { + profile: OneSignalUserProfile? = null, + ): LoginSwitchResult { val jwt = jwtBearerToken?.ifEmpty { null } synchronized(lock) { val currentExternalId = identityModelStore.model.externalId @@ -46,7 +59,14 @@ internal class LoginHelper( jwtTokenStore.putJwt(externalId, jwt) operationRepo.forceExecuteOperations() } - return null + val retryOrUpsert = IDManager.isLocalId(currentOneSignalId) || profile?.hasFields == true + val context = + if (retryOrUpsert) { + LoginEnqueueContext(configModel.appId, currentOneSignalId, externalId, null) + } else { + null + } + return LoginSwitchResult(context, currentOneSignalId) } // Store the JWT before the LoginUserOperation enqueues so that when the op @@ -58,10 +78,6 @@ internal class LoginHelper( } val newOneSignalId = identityModelStore.model.onesignalId - // Under IV-required, the merge-anon-into-identified path can't dispatch — the - // anon user was never created server-side (no JWT) so the local-id reference - // would deadlock LoginUserOperation.canStartExecute. Skip the link entirely so - // the executor takes the createUser (upsert) path. val existingOneSignalId = if (configModel.useIdentityVerification == JwtRequirement.REQUIRED) { null @@ -71,26 +87,71 @@ internal class LoginHelper( null } - return LoginEnqueueContext(configModel.appId, newOneSignalId, externalId, existingOneSignalId) + return LoginSwitchResult( + LoginEnqueueContext(configModel.appId, newOneSignalId, externalId, existingOneSignalId), + newOneSignalId, + ) } } /** * Enqueues the [LoginUserOperation] and suspends until it completes. */ - internal suspend fun enqueueLogin(context: LoginEnqueueContext) { + internal suspend fun enqueueLogin( + context: LoginEnqueueContext, + profile: OneSignalUserProfile? = null, + ): OperationWaitResult { val result = - operationRepo.enqueueAndWait( + operationRepo.enqueueAndAwaitResult( LoginUserOperation( context.appId, context.newIdentityOneSignalId, context.externalId, context.existingOneSignalId, + profile, ), ) - if (!result) { - Logging.warn("Could not login user") + if (!result.success) { + val detail = result.httpStatusCode?.let { "HTTP $it ${result.httpResponse}" } ?: "wait timed out" + Logging.warn("Could not login user: $detail. Local identity is already ${context.externalId}.") } + return result + } + + internal fun loginData( + externalId: String, + profile: OneSignalUserProfile, + wait: OperationWaitResult? = null, + fallbackOnesignalId: String? = null, + ): LoginData { + val subscriptions = subscriptionModelStore.list() + val meta = wait?.metadata as? LoginWaitMetadata + return LoginData( + onesignalId = meta?.onesignalId ?: fallbackOnesignalId ?: identityModelStore.model.onesignalId, + externalId = externalId, + emailSubscriptionId = meta?.emailSubscriptionId + ?: subscriptionId(subscriptions, SubscriptionType.EMAIL, profile.email), + smsSubscriptionId = meta?.smsSubscriptionId + ?: subscriptionId(subscriptions, SubscriptionType.SMS, profile.sms), + ) + } + + internal fun loginDataFromStores( + externalId: String, + profile: OneSignalUserProfile, + ): LoginData = loginData(externalId, profile) + + private fun subscriptionId( + subscriptions: Collection, + type: SubscriptionType, + address: String?, + ): String? { + if (address.isNullOrBlank()) return null + val hashed = PIIHasher.hash(address) + val ignoreCase = type == SubscriptionType.EMAIL + return subscriptions.firstOrNull { sub -> + sub.type == type && (sub.address.equals(address, ignoreCase) || sub.address == hashed) + }?.id } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt index 7cc509d450..1f1572d1b2 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt @@ -23,7 +23,7 @@ interface IUserBackendService { appId: String, identities: Map, subscriptions: List, - properties: Map, + properties: PropertiesObject, jwt: String? = null, ): CreateUserResponse // TODO: Change to send only the push subscription, optimally diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt index 464c4963da..791d8768ca 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt @@ -21,7 +21,7 @@ internal class UserBackendService( appId: String, identities: Map, subscriptions: List, - properties: Map, + properties: PropertiesObject, jwt: String?, ): CreateUserResponse { val requestJSON = JSONObject() @@ -35,8 +35,8 @@ internal class UserBackendService( .put("subscriptions", JSONConverter.convertToJSON(subscriptions)) } - if (properties.isNotEmpty()) { - requestJSON.put("properties", JSONObject().putMap(properties)) + if (properties.hasAtLeastOnePropertySet) { + requestJSON.put("properties", JSONConverter.convertToJSON(properties)) } requestJSON.put("refresh_device_metadata", true) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserOperation.kt index 54a20d03f6..08822cc9f7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserOperation.kt @@ -1,9 +1,15 @@ package com.onesignal.user.internal.operations +import com.onesignal.OneSignalUserProfile import com.onesignal.common.IDManager +import com.onesignal.common.putMap import com.onesignal.core.internal.operations.GroupComparisonType import com.onesignal.core.internal.operations.Operation +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.operations.impl.executors.LoginUserOperationExecutor +import org.json.JSONException +import org.json.JSONObject /** * An [Operation] to login the user with the [externalId] provided. Logging in a user will do the @@ -43,17 +49,72 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) { setOptStringProperty(::existingOnesignalId.name, value) } + internal var email: String? + get() = getOptStringProperty(PROFILE_EMAIL) + set(value) { + setOptStringProperty(PROFILE_EMAIL, value) + } + + internal var sms: String? + get() = getOptStringProperty(PROFILE_SMS) + set(value) { + setOptStringProperty(PROFILE_SMS, value) + } + + internal var tags: Map + get() = decodeMap(getOptStringProperty(PROFILE_TAGS)) + set(value) { + setOptStringProperty(PROFILE_TAGS, encodeMap(value)) + } + + internal var aliases: Map + get() = decodeMap(getOptStringProperty(PROFILE_ALIASES)) + set(value) { + setOptStringProperty(PROFILE_ALIASES, encodeMap(value)) + } + override val createComparisonKey: String get() = "$appId.User.$onesignalId" override val modifyComparisonKey: String = "" override val groupComparisonType: GroupComparisonType = GroupComparisonType.CREATE override val canStartExecute: Boolean get() = existingOnesignalId == null || !IDManager.isLocalId(existingOnesignalId!!) override val applyToRecordId: String get() = existingOnesignalId ?: onesignalId - constructor(appId: String, onesignalId: String, externalId: String?, existingOneSignalId: String? = null) : this() { + constructor( + appId: String, + onesignalId: String, + externalId: String?, + existingOneSignalId: String? = null, + profile: OneSignalUserProfile? = null, + ) : this() { this.appId = appId this.onesignalId = onesignalId this.externalId = externalId this.existingOnesignalId = existingOneSignalId + if (profile != null) { + this.email = profile.email?.takeIf { it.isNotBlank() } + this.sms = profile.sms?.takeIf { it.isNotBlank() } + this.tags = profile.tags + this.aliases = profile.aliases + } + } + + internal fun hasProfileFields(): Boolean = + !email.isNullOrBlank() || !sms.isNullOrBlank() || tags.isNotEmpty() || aliases.isNotEmpty() + + internal fun mergeProfileFrom(other: LoginUserOperation) { + if (!other.email.isNullOrBlank()) email = other.email + if (!other.sms.isNullOrBlank()) sms = other.sms + if (other.tags.isNotEmpty()) tags = tags + other.tags + if (other.aliases.isNotEmpty()) aliases = aliases + other.aliases + } + + override fun toString(): String { + val json = toJSON() + if (!email.isNullOrBlank()) json.put(PROFILE_EMAIL, "") + if (!sms.isNullOrBlank()) json.put(PROFILE_SMS, "") + if (tags.isNotEmpty()) json.put(PROFILE_TAGS, tags.size) + if (aliases.isNotEmpty()) json.put(PROFILE_ALIASES, aliases.size) + return json.toString() } override fun translateIds(map: Map) { @@ -62,3 +123,33 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) { } } } + +internal fun reservedLoginAliasLabel(label: String): Boolean = + label.isBlank() || label == IdentityConstants.ONESIGNAL_ID || label == IdentityConstants.EXTERNAL_ID + +private const val PROFILE_EMAIL = "profileEmail" +private const val PROFILE_SMS = "profileSms" +private const val PROFILE_TAGS = "profileTags" +private const val PROFILE_ALIASES = "profileAliases" + +private fun encodeMap(map: Map): String? { + if (map.isEmpty()) return null + return JSONObject().putMap(map).toString() +} + +private fun decodeMap(json: String?): Map { + if (json.isNullOrEmpty()) return emptyMap() + return try { + val obj = JSONObject(json) + val result = mutableMapOf() + val keys = obj.keys() + while (keys.hasNext()) { + val key = keys.next() + result[key] = obj.optString(key) + } + result + } catch (e: JSONException) { + Logging.warn("LoginUserOperation: ignoring malformed profile map", e) + emptyMap() + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt new file mode 100644 index 0000000000..e668ba6ff5 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt @@ -0,0 +1,117 @@ +package com.onesignal.user.internal.operations.impl.executors + +import com.onesignal.common.PIIHasher +import com.onesignal.common.modeling.ModelChangeTags +import com.onesignal.user.internal.backend.CreateUserResponse +import com.onesignal.user.internal.backend.SubscriptionObject +import com.onesignal.user.internal.backend.SubscriptionObjectType +import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.operations.LoginUserOperation +import com.onesignal.user.internal.operations.reservedLoginAliasLabel +import com.onesignal.user.internal.properties.PropertiesModel +import com.onesignal.user.internal.properties.PropertiesModelStore +import com.onesignal.user.internal.subscriptions.SubscriptionModel +import com.onesignal.user.internal.subscriptions.SubscriptionModelStore +import com.onesignal.user.internal.subscriptions.SubscriptionStatus +import com.onesignal.user.internal.subscriptions.SubscriptionType + +internal object LoginProfileApplier { + const val PROFILE_EMAIL_KEY = "__profile_email" + const val PROFILE_SMS_KEY = "__profile_sms" + + fun isProfileSubscriptionKey(key: String): Boolean = key == PROFILE_EMAIL_KEY || key == PROFILE_SMS_KEY + + fun addSubscriptions( + op: LoginUserOperation, + subscriptions: Map, + ): Map { + val mutable = subscriptions.toMutableMap() + val email = op.email?.takeIf { it.isNotBlank() } + if (email != null && mutable.values.none { it.type == SubscriptionObjectType.EMAIL && it.token == email }) { + mutable[PROFILE_EMAIL_KEY] = SubscriptionObject(type = SubscriptionObjectType.EMAIL, token = email) + } + val sms = op.sms?.takeIf { it.isNotBlank() } + if (sms != null && mutable.values.none { it.type == SubscriptionObjectType.SMS && it.token == sms }) { + mutable[PROFILE_SMS_KEY] = SubscriptionObject(type = SubscriptionObjectType.SMS, token = sms) + } + return mutable + } + + /** + * The backend wins where it spoke, the request fills the rest. Never deletes: Create User can + * echo a partial body, and dropping keys it omitted would discard state we just sent. + */ + fun hydrate( + op: LoginUserOperation, + response: CreateUserResponse, + identityModelStore: IdentityModelStore, + propertiesModelStore: PropertiesModelStore, + ) { + val identityModel = identityModelStore.model + // onesignal_id and external_id arrive on every response and are owned by the executor. + for ((label, id) in op.aliases + response.identities) { + if (reservedLoginAliasLabel(label)) continue + identityModel.setStringProperty(label, id, ModelChangeTags.HYDRATE) + } + + val propertiesModel = propertiesModelStore.model + response.properties.language?.let { + propertiesModel.setStringProperty(PropertiesModel::language.name, it, ModelChangeTags.HYDRATE) + } + response.properties.country?.let { + propertiesModel.setStringProperty(PropertiesModel::country.name, it, ModelChangeTags.HYDRATE) + } + + val backendTags = response.properties.tags.orEmpty().mapNotNull { (key, value) -> value?.let { key to it } } + val tagsModel = propertiesModel.tags + for ((key, value) in op.tags + backendTags) { + tagsModel.setStringProperty(key, value, ModelChangeTags.HYDRATE) + } + } + + fun persistSubscription( + backend: SubscriptionObject, + fallbackToken: String?, + subscriptionsModelStore: SubscriptionModelStore, + ) { + val id = backend.id + val token = backend.token ?: fallbackToken + val type = subscriptionType(backend.type) + if (id == null || token == null || type == null) return + + val ignoreCase = type == SubscriptionType.EMAIL + val hashed = PIIHasher.hash(token) + val existing = + subscriptionsModelStore.list().firstOrNull { sub -> + sub.type == type && (sub.address.equals(token, ignoreCase) || sub.address == hashed) + } + if (existing != null) { + existing.setStringProperty(SubscriptionModel::id.name, id, ModelChangeTags.HYDRATE) + return + } + val model = SubscriptionModel() + model.id = id + model.type = type + model.address = token + model.status = SubscriptionStatus.SUBSCRIBED + model.optedIn = true + subscriptionsModelStore.add(model, ModelChangeTags.HYDRATE) + } + + fun subscriptionType(type: SubscriptionObjectType?): SubscriptionType? = + when (type) { + SubscriptionObjectType.EMAIL -> SubscriptionType.EMAIL + SubscriptionObjectType.SMS -> SubscriptionType.SMS + else -> null + } + + fun fallbackToken( + backendType: SubscriptionObjectType?, + op: LoginUserOperation, + ): String? = + when (subscriptionType(backendType)) { + SubscriptionType.EMAIL -> op.email + SubscriptionType.SMS -> op.sms + else -> null + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt index 0fb11d74a2..2a359e9106 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt @@ -6,6 +6,7 @@ import com.onesignal.common.DeviceUtils import com.onesignal.common.IDManager import com.onesignal.common.NetworkUtils import com.onesignal.common.OneSignalUtils +import com.onesignal.common.PIIHasher import com.onesignal.common.RootToolsInternalMethods import com.onesignal.common.TimeUtils import com.onesignal.common.consistency.enums.IamFetchRywTokenKey @@ -21,10 +22,12 @@ import com.onesignal.core.internal.language.ILanguageContext import com.onesignal.core.internal.operations.ExecutionResponse import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.IOperationExecutor +import com.onesignal.core.internal.operations.LoginWaitMetadata import com.onesignal.core.internal.operations.Operation import com.onesignal.debug.internal.logging.Logging import com.onesignal.user.internal.backend.IUserBackendService import com.onesignal.user.internal.backend.IdentityConstants +import com.onesignal.user.internal.backend.PropertiesObject import com.onesignal.user.internal.backend.SubscriptionObject import com.onesignal.user.internal.backend.SubscriptionObjectType import com.onesignal.user.internal.identity.IdentityModelStore @@ -36,6 +39,7 @@ import com.onesignal.user.internal.operations.RefreshUserOperation import com.onesignal.user.internal.operations.SetAliasOperation import com.onesignal.user.internal.operations.TransferSubscriptionOperation import com.onesignal.user.internal.operations.UpdateSubscriptionOperation +import com.onesignal.user.internal.operations.reservedLoginAliasLabel import com.onesignal.user.internal.properties.PropertiesModel import com.onesignal.user.internal.properties.PropertiesModelStore import com.onesignal.user.internal.subscriptions.SubscriptionModel @@ -84,9 +88,7 @@ internal class LoginUserOperationExecutor( if (!containsSubscriptionOperation && alias == null) { return ExecutionResponse(ExecutionResult.FAIL_NORETRY) } - if (loginUserOp.existingOnesignalId == null || alias == null || - _identityVerificationService.ivBehaviorActive - ) { + if (shouldCreateUserDirectly(loginUserOp, alias)) { // When there is no existing user to attempt to associate with the externalId provided, we go right to // createUser. If there is no externalId provided this is an insert, if there is this will be an // "upsert with retrieval" as the user may already exist. @@ -101,15 +103,16 @@ internal class LoginUserOperationExecutor( // before we create a user we attempt to associate the user defined by existingOnesignalId with the // externalId provided. If that association cannot be made, typically because the externalId is already // associated to a user, we fall back to our "upsert with retrieval" method. + val mergeAlias = alias!! val result = _identityOperationExecutor.execute( listOf( SetAliasOperation( loginUserOp.appId, loginUserOp.existingOnesignalId!!, - loginUserOp.externalId, + mergeAlias, IdentityConstants.EXTERNAL_ID, - loginUserOp.externalId!!, + mergeAlias, ), ), ) @@ -135,7 +138,11 @@ internal class LoginUserOperationExecutor( ) } - ExecutionResponse(ExecutionResult.SUCCESS_STARTING_ONLY, mapOf(loginUserOp.onesignalId to backendOneSignalId)) + ExecutionResponse( + ExecutionResult.SUCCESS_STARTING_ONLY, + mapOf(loginUserOp.onesignalId to backendOneSignalId), + metadata = LoginWaitMetadata(backendOneSignalId), + ) } ExecutionResult.FAIL_CONFLICT -> { // When the SetAliasOperation fails with conflict that *most likely* means the externalId provided @@ -171,17 +178,24 @@ internal class LoginUserOperationExecutor( createUserOperation: LoginUserOperation, operations: List, ): ExecutionResponse { - var identities = mapOf() + val identities = mutableMapOf() var subscriptions = mapOf() - val properties = mutableMapOf() - properties["timezone_id"] = TimeUtils.getTimeZoneId() - properties["language"] = _languageContext.language - - val externalId = createUserOperation.externalId?.takeUnless { hasNullByte(it) } - if (externalId != null) { - val mutableIdentities = identities.toMutableMap() - mutableIdentities[IdentityConstants.EXTERNAL_ID] = externalId - identities = mutableIdentities + val properties = + PropertiesObject( + tags = createUserOperation.tags.takeIf { it.isNotEmpty() }, + language = _languageContext.language, + timezoneId = TimeUtils.getTimeZoneId(), + ) + + createUserOperation.externalId?.takeUnless { hasNullByte(it) }?.let { + identities[IdentityConstants.EXTERNAL_ID] = it + } + for ((label, id) in createUserOperation.aliases) { + if (reservedLoginAliasLabel(label) || hasNullByte(label) || hasNullByte(id)) { + Logging.warn("LoginUserOperationExecutor: skipping reserved or invalid alias label") + continue + } + identities[label] = id } // go through the operations grouped with this create user and apply them to the appropriate objects. @@ -194,6 +208,7 @@ internal class LoginUserOperationExecutor( else -> throw Exception("Unrecognized operation: $operation") } } + subscriptions = LoginProfileApplier.addSubscriptions(createUserOperation, subscriptions) try { val subscriptionList = subscriptions.toList() @@ -220,30 +235,41 @@ internal class LoginUserOperationExecutor( } val backendSubscriptions = response.subscriptions.toMutableSet() + var emailSubscriptionId: String? = null + var smsSubscriptionId: String? = null for (pair in subscriptionList) { - // Find the corresponding subscription (subscriptions are not returned in the order they are sent) - // 1. Start by matching the subscription ID - var backendSubscription = backendSubscriptions.firstOrNull { it.id == pair.first } - // 2. If ID fails, match the token, this should always succeed for email or sms - if (backendSubscription == null) { - backendSubscription = backendSubscriptions.firstOrNull { it.token == pair.second.token && !it.token.isNullOrBlank() } - } - // 3. Match by type. By this point, only a single push subscription should remain, at most - if (backendSubscription == null) { - backendSubscription = backendSubscriptions.firstOrNull { it.type == pair.second.type } - } + val backendSubscription = matchingBackendSubscription(backendSubscriptions, pair.first, pair.second) if (backendSubscription != null) { - idTranslations[pair.first] = backendSubscription.id!! + if (!LoginProfileApplier.isProfileSubscriptionKey(pair.first)) { + idTranslations[pair.first] = backendSubscription.id!! + } if (_configModelStore.model.pushSubscriptionId == pair.first) { _configModelStore.model.pushSubscriptionId = backendSubscription.id } val subscriptionModel = _subscriptionsModelStore.get(pair.first) - subscriptionModel?.setStringProperty(SubscriptionModel::id.name, backendSubscription.id!!, ModelChangeTags.HYDRATE) - } else { + if (subscriptionModel != null) { + subscriptionModel.setStringProperty(SubscriptionModel::id.name, backendSubscription.id!!, ModelChangeTags.HYDRATE) + } else if (LoginProfileApplier.isProfileSubscriptionKey(pair.first)) { + if (_identityModelStore.model.onesignalId == backendOneSignalId) { + LoginProfileApplier.persistSubscription( + backendSubscription, + LoginProfileApplier.fallbackToken(backendSubscription.type, createUserOperation), + _subscriptionsModelStore, + ) + } + when (LoginProfileApplier.subscriptionType(backendSubscription.type)) { + SubscriptionType.EMAIL -> emailSubscriptionId = backendSubscription.id + SubscriptionType.SMS -> smsSubscriptionId = backendSubscription.id + else -> {} + } + } else { + Logging.error("LoginUserOperationExecutor.createUser response is missing a local subscription model for ${pair.first}") + } + } else if (!LoginProfileApplier.isProfileSubscriptionKey(pair.first)) { Logging.error("LoginUserOperationExecutor.createUser response is missing subscription data for ${pair.first}") } @@ -261,6 +287,20 @@ internal class LoginUserOperationExecutor( } } + if (_identityModelStore.model.onesignalId == backendOneSignalId) { + LoginProfileApplier.hydrate(createUserOperation, response, _identityModelStore, _propertiesModelStore) + } else if (createUserOperation.hasProfileFields()) { + Logging.warn("LoginUserOperationExecutor: skipped profile hydration because the current identity is not the created user") + } + + if (!_identityVerificationService.ivBehaviorActive && + (!createUserOperation.email.isNullOrBlank() || !createUserOperation.sms.isNullOrBlank()) + ) { + Logging.warn( + "LoginUserOperationExecutor: email or SMS sent without identity verification. If that address belonged to another user it was transferred.", + ) + } + val wasPossiblyAnUpsert = identities.isNotEmpty() val followUpOperations = if (wasPossiblyAnUpsert) { @@ -269,17 +309,23 @@ internal class LoginUserOperationExecutor( null } - return ExecutionResponse(ExecutionResult.SUCCESS, idTranslations, followUpOperations) + return ExecutionResponse( + ExecutionResult.SUCCESS, + idTranslations, + followUpOperations, + metadata = LoginWaitMetadata(backendOneSignalId, emailSubscriptionId, smsSubscriptionId), + ) } catch (ex: BackendException) { + Logging.error("LoginUserOperationExecutor.createUser failed: HTTP ${ex.statusCode}") val responseType = NetworkUtils.getResponseStatusType(ex.statusCode) return when (responseType) { NetworkUtils.ResponseStatusType.RETRYABLE -> - ExecutionResponse(ExecutionResult.FAIL_RETRY, retryAfterSeconds = ex.retryAfterSeconds) + backendExecutionResponse(ExecutionResult.FAIL_RETRY, ex) NetworkUtils.ResponseStatusType.UNAUTHORIZED -> - ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED, retryAfterSeconds = ex.retryAfterSeconds) + backendExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED, ex) else -> - ExecutionResponse(ExecutionResult.FAIL_PAUSE_OPREPO) + backendExecutionResponse(ExecutionResult.FAIL_NORETRY, ex) } } } @@ -384,7 +430,57 @@ internal class LoginUserOperationExecutor( return mutableSubscriptions } + private fun shouldCreateUserDirectly(op: LoginUserOperation, alias: String?): Boolean { + if (op.hasProfileFields() || _identityVerificationService.ivBehaviorActive) return true + return op.existingOnesignalId == null || alias == null + } + + private fun backendExecutionResponse( + result: ExecutionResult, + ex: BackendException, + ) = ExecutionResponse( + result, + retryAfterSeconds = ex.retryAfterSeconds, + httpStatusCode = ex.statusCode, + httpResponse = ex.response, + ) + companion object { const val LOGIN_USER = "login-user" } } + +// Find the corresponding subscription (subscriptions are not returned in the order they are sent) +internal fun matchingBackendSubscription( + remaining: Set, + localId: String, + local: SubscriptionObject, +): SubscriptionObject? = + remaining.firstOrNull { it.id == localId } + ?: matchByToken(remaining, local) + ?: matchByType(remaining, local) + +// Should always succeed for email or sms once the ID match fails. +private fun matchByToken( + remaining: Set, + local: SubscriptionObject, +): SubscriptionObject? { + val localToken = local.token + if (localToken.isNullOrBlank()) return null + val ignoreCase = local.type == SubscriptionObjectType.EMAIL + val hashed = PIIHasher.hash(localToken) + return remaining.firstOrNull { backend -> + val token = backend.token + !token.isNullOrBlank() && (token.equals(localToken, ignoreCase) || token == hashed) + } +} + +// Push: at most one remains. Email/SMS: only if one remains and it has no token. +private fun matchByType( + remaining: Set, + local: SubscriptionObject, +): SubscriptionObject? { + val sameType = remaining.filter { it.type == local.type } + val isEmailOrSms = local.type == SubscriptionObjectType.EMAIL || local.type == SubscriptionObjectType.SMS + return if (isEmailOrSms) sameType.singleOrNull()?.takeIf { it.token.isNullOrBlank() } else sameType.firstOrNull() +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalErrorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalErrorTests.kt new file mode 100644 index 0000000000..c0552e55a2 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalErrorTests.kt @@ -0,0 +1,66 @@ +package com.onesignal + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe + +class OneSignalErrorTests : FunSpec({ + + test("fromBackendResponse parses catalog code, title, and conflicting_aliases") { + val body = + """ + {"errors":[{ + "code":"user-1", + "title":"Multiple existing Users found with provided set of Aliases", + "meta":{"conflicting_aliases":{"custom_alias_1":"jon-api-1","external_id":"jon-api-3"}} + }]} + """.trimIndent() + + val error = OneSignalError.fromBackendResponse(409, body) + + error.first.code shouldBe ErrorCode.BACKEND_ERROR + error.first.backendCode shouldBe "user-1" + error.first.httpStatus shouldBe 409 + error.first.message shouldBe "Multiple existing Users found with provided set of Aliases" + error.first.meta shouldBe + mapOf( + "conflicting_aliases" to + mapOf( + "custom_alias_1" to "jon-api-1", + "external_id" to "jon-api-3", + ), + ) + } + + test("fromBackendResponse maps each errors entry to its own reason") { + val body = + """ + {"errors":[ + {"code":"user-1","title":"alias conflict"}, + {"code":"user-3","title":"invalid external id"} + ]} + """.trimIndent() + + val error = OneSignalError.fromBackendResponse(409, body) + + error.error.map { it.backendCode } shouldBe listOf("user-1", "user-3") + error.error.map { it.message } shouldBe listOf("alias conflict", "invalid external id") + error.error.all { it.httpStatus == 409 } shouldBe true + } + + test("fromBackendResponse keeps a plain body as the message") { + val error = OneSignalError.fromBackendResponse(409, "CONFLICT", fallbackMessage = "Login did not complete.") + + error.first.backendCode.shouldBeNull() + error.first.httpStatus shouldBe 409 + error.first.message shouldBe "CONFLICT" + error.first.meta.shouldBeNull() + } + + test("fromBackendResponse uses the fallback when the body is empty") { + val error = OneSignalError.fromBackendResponse(400, " ", fallbackMessage = "Login did not complete (HTTP 400).") + + error.first.httpStatus shouldBe 400 + error.first.message shouldBe "Login did not complete (HTTP 400)." + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalResultTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalResultTests.kt new file mode 100644 index 0000000000..caeefb18c6 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalResultTests.kt @@ -0,0 +1,616 @@ +package com.onesignal + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.assertions.withClue +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import io.kotest.matchers.types.shouldBeInstanceOf +import org.json.JSONArray +import org.json.JSONObject + +class OneSignalResultTests : FunSpec({ + + fun reasonMap( + code: String, + source: String, + message: String? = null, + backendCode: String? = null, + httpStatus: Int? = null, + meta: Map? = null, + ) = mapOf( + "code" to code, + "source" to source, + "backendCode" to backendCode, + "httpStatus" to httpStatus, + "message" to message, + "meta" to meta, + ) + + test("success carries data and no error") { + val result = OneSignalResult.success(LoginData("os-1", "ext-1")) + + result.isSuccess.shouldBeTrue() + result.error.shouldBeNull() + result.data.shouldNotBeNull() + result.data!!.onesignalId shouldBe "os-1" + result.data!!.externalId shouldBe "ext-1" + } + + test("failure carries error and no data") { + val result = OneSignalResult.failure(ErrorCode.INVALID_ARGUMENT, "no app ID") + + result.isSuccess.shouldBeFalse() + result.data.shouldBeNull() + result.error.shouldNotBeNull() + result.error!!.first.code shouldBe ErrorCode.INVALID_ARGUMENT + result.error!!.first.message shouldBe "no app ID" + } + + test("a client code is distinguishable from a backend one without inspecting the message") { + val client = OneSignalResult.failure(ErrorCode.STORAGE_LOCKED, "device locked") + val backend = + OneSignalResult.failure( + ErrorCode.BACKEND_ERROR, + "Invalid API Key", + backendCode = "user-1", + httpStatus = 400, + ) + + client.error!!.first.source shouldBe ErrorSource.CLIENT + client.error!!.first.backendCode.shouldBeNull() + client.error!!.first.httpStatus.shouldBeNull() + + backend.error!!.first.source shouldBe ErrorSource.BACKEND + backend.error!!.first.backendCode shouldBe "user-1" + backend.error!!.first.httpStatus shouldBe 400 + } + + test("an error can carry several reasons at once") { + val error = + OneSignalError.of( + listOf( + OneSignalError.Detail.of(ErrorCode.BACKEND_ERROR, backendCode = "user-1", message = "Invalid API Key"), + OneSignalError.Detail.of(ErrorCode.BACKEND_ERROR, backendCode = "user-3", message = "Invalid external ID"), + ), + ) + + error.error.size shouldBe 2 + error.first.backendCode shouldBe "user-1" + error.toList().map { it["backendCode"] } shouldBe listOf("user-1", "user-3") + } + + // first is documented as always safe to read, so the factories have to refuse the one input + // that would make it throw. The constructor is private; this is the supported construction path. + test("an error cannot be built with no reasons") { + shouldThrow { OneSignalError.of(emptyList()) } + } + + test("the cause stays off the wire so every SDK serializes the same shape") { + val error = OneSignalError.of(ErrorCode.UNKNOWN, "boom", cause = IllegalStateException("boom")) + + error.cause.shouldNotBeNull() + error.toList().single().keys shouldBe setOf("code", "source", "backendCode", "httpStatus", "message", "meta") + } + + test("success projects onto the wire envelope") { + val map = OneSignalResult.success(LoginData("os-1", "ext-1")).toMap() + + map shouldBe + mapOf( + "success" to true, + "data" to mapOf("onesignalId" to "os-1", "externalId" to "ext-1"), + "error" to null, + ) + } + + test("failure projects onto the wire envelope") { + val map = OneSignalResult.failure(ErrorCode.STORAGE_LOCKED, "device locked").toMap() + + map shouldBe + mapOf( + "success" to false, + "data" to null, + "error" to + listOf( + reasonMap("STORAGE_LOCKED", "CLIENT", message = "device locked"), + ), + ) + } + + test("an empty payload still serializes as a present, empty data object") { + val map = OneSignalResult.success(InitData()).toMap() + + map["success"] shouldBe true + map["error"].shouldBeNull() + map.containsKey("data").shouldBeTrue() + map["data"] shouldBe emptyMap() + } + + test("success round-trips through the wire shape") { + val original = OneSignalResult.success(LoginData("os-1", "ext-1")) + + val restored = OneSignalResult.fromMap(original.toMap(), LoginData::fromMap) + + restored.isSuccess.shouldBeTrue() + restored.toMap() shouldBe original.toMap() + } + + test("failure round-trips through the wire shape") { + val original = + OneSignalResult.failure( + ErrorCode.BACKEND_ERROR, + "already linked", + backendCode = "user-1", + httpStatus = 409, + ) + + val restored = OneSignalResult.fromMap(original.toMap(), LoginData::fromMap) + + restored.isSuccess.shouldBeFalse() + restored.toMap() shouldBe original.toMap() + restored.error!!.first.backendCode shouldBe "user-1" + restored.error!!.first.httpStatus shouldBe 409 + } + + test("catalog code, http status, and meta round-trip on the wire") { + val original = + OneSignalResult.failure( + OneSignalError.of( + ErrorCode.BACKEND_ERROR, + message = "Multiple existing Users found with provided set of Aliases", + backendCode = "user-1", + httpStatus = 409, + meta = mapOf("conflicting_aliases" to mapOf("external_id" to "jon-api-3")), + ), + ) + + val restored = OneSignalResult.fromMap(original.toMap(), LoginData::fromMap) + + restored.error!!.first.backendCode shouldBe "user-1" + restored.error!!.first.httpStatus shouldBe 409 + restored.error!!.first.meta shouldBe mapOf("conflicting_aliases" to mapOf("external_id" to "jon-api-3")) + restored.toMap() shouldBe original.toMap() + } + + test("an empty payload round-trips through the wire shape") { + val original = OneSignalResult.success(InitData()) + + val restored = OneSignalResult.fromMap(original.toMap(), InitData::fromMap) + + restored.isSuccess.shouldBeTrue() + restored.toMap() shouldBe original.toMap() + } + + test("unknown envelope and payload fields are ignored rather than throwing") { + val fromNewerProducer = + mapOf( + "success" to true, + "data" to + mapOf( + "onesignalId" to "os-1", + "externalId" to "ext-1", + "subscriptionId" to "sub-9", + ), + "error" to null, + "traceId" to "abc-123", + ) + + val restored = OneSignalResult.fromMap(fromNewerProducer, LoginData::fromMap) + + restored.isSuccess.shouldBeTrue() + restored.data!!.onesignalId shouldBe "os-1" + restored.toMap() shouldBe + mapOf( + "success" to true, + "data" to mapOf("onesignalId" to "os-1", "externalId" to "ext-1"), + "error" to null, + ) + } + + // The enum is closed, so a wrapper running against a newer producer will eventually meet a + // code it cannot name. It has to degrade rather than throw out of valueOf. + test("an unrecognized code degrades to UNKNOWN with the message preserved") { + val fromNewerProducer = + mapOf( + "success" to false, + "data" to null, + "error" to listOf(mapOf("code" to "RATE_LIMITED", "message" to "slow down", "retryAfterSeconds" to 30)), + ) + + val restored = OneSignalResult.fromMap(fromNewerProducer, LoginData::fromMap) + + restored.isSuccess.shouldBeFalse() + restored.error!!.first.code shouldBe ErrorCode.UNKNOWN + restored.error!!.first.message shouldBe "slow down" + } + + test("a malformed error missing its code degrades to unknown instead of throwing") { + val restored = + OneSignalResult.fromMap( + mapOf("success" to false, "data" to null, "error" to listOf(mapOf("message" to "something broke"))), + LoginData::fromMap, + ) + + restored.isSuccess.shouldBeFalse() + restored.error!!.first.code shouldBe ErrorCode.UNKNOWN + restored.error!!.first.message shouldBe "something broke" + } + + // first is documented as always safe to read, so an error list that arrives empty still has + // to produce one reason rather than blowing up at the call site. + test("an empty reason list still yields a readable error") { + val restored = + OneSignalResult.fromMap( + mapOf("success" to false, "data" to null, "error" to emptyList>()), + LoginData::fromMap, + ) + + restored.isSuccess.shouldBeFalse() + restored.error!!.first.code shouldBe ErrorCode.UNKNOWN + } + + test("error presence wins over a contradictory success flag") { + val contradictory = + mapOf( + "success" to true, + "data" to mapOf("onesignalId" to "os-1", "externalId" to "ext-1"), + "error" to listOf(mapOf("code" to "STORAGE_LOCKED", "message" to "device locked")), + ) + + val restored = OneSignalResult.fromMap(contradictory, LoginData::fromMap) + + restored.isSuccess.shouldBeFalse() + restored.data.shouldBeNull() + restored.error!!.first.code shouldBe ErrorCode.STORAGE_LOCKED + } + + // The version above only holds when the error happens to be a well-typed List. The rule is that + // a present error wins whatever shape it arrives in, so the awkward shapes are checked too. + test("error presence wins over a contradictory success flag whatever shape the error arrives in") { + val shapes = + listOf( + JSONArray("""[{"code":"STORAGE_LOCKED"}]"""), + mapOf("code" to "STORAGE_LOCKED"), + "STORAGE_LOCKED", + listOf("STORAGE_LOCKED"), + ) + + shapes.forEach { shape -> + val restored = + OneSignalResult.fromMap( + mapOf( + "success" to true, + "data" to mapOf("onesignalId" to "os-1", "externalId" to "ext-1"), + "error" to shape, + ), + LoginData::fromMap, + ) + + withClue("error carried as ${shape.javaClass.simpleName}") { + restored.isSuccess.shouldBeFalse() + restored.data.shouldBeNull() + restored.error.shouldNotBeNull() + } + } + } + + // org.json is what a bridge naturally parses with, and JSONArray is not a java.util.List. Reading + // the error with a cast that doubled as the predicate turned that into a blank success; treating + // the whole array as one opaque reason would also lose the codes. Convert and parse each entry. + test("an error arriving as a JSONArray still reports a failure") { + val fromBridge = + mapOf( + "success" to false, + "data" to null, + "error" to JSONArray("""[{"code":"STORAGE_LOCKED","source":"CLIENT","message":"device locked"}]"""), + ) + + val restored = OneSignalResult.fromMap(fromBridge, LoginData::fromMap) + + restored.isSuccess.shouldBeFalse() + restored.data.shouldBeNull() + restored.error!!.first.code shouldBe ErrorCode.STORAGE_LOCKED + restored.error!!.first.message shouldBe "device locked" + restored.error!!.first.source shouldBe ErrorSource.CLIENT + } + + test("an error list holding a reason that is not a map reports a failure instead of throwing") { + val restored = + OneSignalResult.fromMap( + mapOf("success" to false, "data" to null, "error" to listOf("something broke")), + LoginData::fromMap, + ) + + restored.isSuccess.shouldBeFalse() + restored.error!!.first.code shouldBe ErrorCode.UNKNOWN + restored.error!!.first.message shouldBe "something broke" + } + + test("a reason list mixing a readable reason with an unreadable one keeps both") { + val restored = + OneSignalResult.fromMap( + mapOf("error" to listOf(mapOf("code" to "STORAGE_LOCKED"), 42)), + LoginData::fromMap, + ) + + restored.error!!.error.size shouldBe 2 + restored.error!!.error[0].code shouldBe ErrorCode.STORAGE_LOCKED + restored.error!!.error[1].code shouldBe ErrorCode.UNKNOWN + restored.error!!.error[1].message shouldBe "42" + } + + // isSuccess reads error, callers read data. Neither or both leaves the two disagreeing. + test("a result cannot be built carrying neither data nor error") { + val ctor = + OneSignalResult::class.java.getDeclaredConstructor( + OneSignalResultData::class.java, + OneSignalError::class.java, + ) + ctor.isAccessible = true + + val thrown = + shouldThrow { + ctor.newInstance(null, null) + } + thrown.cause.shouldBeInstanceOf() + } + + test("a result cannot be built carrying both data and error") { + val ctor = + OneSignalResult::class.java.getDeclaredConstructor( + OneSignalResultData::class.java, + OneSignalError::class.java, + ) + ctor.isAccessible = true + + val thrown = + shouldThrow { + ctor.newInstance(LoginData("os-1", "ext-1"), OneSignalError.of(ErrorCode.UNKNOWN)) + } + thrown.cause.shouldBeInstanceOf() + } + + // The copy stops a caller emptying the list that was passed in, but Java can still clear the + // copy itself, and first is documented as always safe to read. + test("the reason list handed to callers cannot be emptied") { + val restored = + OneSignalResult.fromMap( + mapOf("error" to listOf(mapOf("code" to "BACKEND_ERROR"), mapOf("code" to "STORAGE_LOCKED"))), + LoginData::fromMap, + ) + + @Suppress("UNCHECKED_CAST") + shouldThrow { (restored.error!!.error as MutableList).clear() } + + restored.error!!.first.code shouldBe ErrorCode.BACKEND_ERROR + } + + // Attribution is the point of source. Re-deriving it from a code that has already degraded to + // UNKNOWN hands back a client-attributed error carrying a backend code. + test("an unrecognized code keeps the source the producer sent") { + val fromNewerProducer = + mapOf( + "success" to false, + "data" to null, + "error" to + listOf( + mapOf("code" to "RATE_LIMITED", "source" to "BACKEND", "httpStatus" to 429, "message" to "slow down"), + ), + ) + + val restored = OneSignalResult.fromMap(fromNewerProducer, LoginData::fromMap) + + restored.error!!.first.code shouldBe ErrorCode.UNKNOWN + restored.error!!.first.backendCode.shouldBeNull() + restored.error!!.first.httpStatus shouldBe 429 + restored.error!!.first.source shouldBe ErrorSource.BACKEND + // Also checked through the wire projection, since toMap and fromMap have to stay symmetric. + restored.error!!.toList().first()["source"] shouldBe "BACKEND" + } + + // The mirror of the error case: data was read with a cast that doubled as the presence check, + // so a payload the parser could not type read as no payload at all and was replaced with a + // blank one, under a result still claiming success. JSONObject is the bridge's natural map + // shape, so convert it rather than reporting a failure that contradicts a successful producer. + test("a payload arriving as a JSONObject is read as a success") { + val fromBridge = + mapOf( + "success" to true, + "data" to JSONObject("""{"onesignalId":"os-1","externalId":"ext-1"}"""), + "error" to null, + ) + + val restored = OneSignalResult.fromMap(fromBridge, LoginData::fromMap) + + restored.isSuccess.shouldBeTrue() + restored.data!!.onesignalId shouldBe "os-1" + restored.data!!.externalId shouldBe "ext-1" + } + + test("a payload that is not a map at all reports a failure") { + val restored = + OneSignalResult.fromMap( + mapOf("success" to true, "data" to "os-1", "error" to null), + LoginData::fromMap, + ) + + restored.isSuccess.shouldBeFalse() + restored.error!!.first.code shouldBe ErrorCode.UNKNOWN + } + + // Naming the type is enough to diagnose the bridge; the payload itself is identity data and has + // no business being copied into an error message that will be logged. + test("an unreadable payload is named by its type without its contents being echoed") { + val restored = + OneSignalResult.fromMap( + mapOf("data" to listOf("os-1", "ext-1")), + LoginData::fromMap, + ) + + val message = restored.error!!.first.message!! + message shouldContain "ArrayList" + message shouldNotContain "os-1" + message shouldNotContain "ext-1" + } + + // Regression guard, not evidence: this passes before the fix too. The empty payload types + // serialize to no fields, so an absent or null data has to keep meaning an empty payload + // rather than being swept up as unreadable. + test("an absent or null payload still yields an empty payload rather than a failure") { + listOf(mapOf("success" to true), mapOf("success" to true, "data" to null)).forEach { envelope -> + withClue("envelope $envelope") { + val restored = OneSignalResult.fromMap(envelope, InitData::fromMap) + + restored.isSuccess.shouldBeTrue() + restored.data.shouldNotBeNull() + } + } + } + + test("a reason with no source on the wire falls back to the source its code implies") { + val restored = + OneSignalResult.fromMap( + mapOf("error" to listOf(mapOf("code" to "BACKEND_ERROR", "backendCode" to "user-1"))), + LoginData::fromMap, + ) + + restored.error!!.first.source shouldBe ErrorSource.BACKEND + restored.error!!.toList().first()["source"] shouldBe "BACKEND" + } + + // Required identity fields cannot be invented. A missing, null, empty, or wrongly typed + // onesignalId/externalId used to become LoginData("", "") under isSuccess == true. + test("LoginData with missing, null, empty, or wrongly typed identity fields is a failure") { + val badPayloads = + listOf( + emptyMap(), + mapOf("onesignalId" to "os-1"), + mapOf("externalId" to "ext-1"), + mapOf("onesignalId" to "os-1", "externalId" to null), + mapOf("onesignalId" to null, "externalId" to "ext-1"), + mapOf("onesignalId" to "", "externalId" to "ext-1"), + mapOf("onesignalId" to "os-1", "externalId" to ""), + mapOf("onesignalId" to 1, "externalId" to "ext-1"), + mapOf("onesignalId" to "os-1", "externalId" to 1), + mapOf("onesignalId" to JSONObject.NULL, "externalId" to "ext-1"), + JSONObject("""{"onesignalId":"os-1"}"""), + ) + + badPayloads.forEach { payload -> + withClue("payload $payload") { + val restored = + OneSignalResult.fromMap( + mapOf("success" to true, "data" to payload, "error" to null), + LoginData::fromMap, + ) + + restored.isSuccess.shouldBeFalse() + restored.data.shouldBeNull() + restored.error!!.first.code shouldBe ErrorCode.UNKNOWN + restored.error!!.first.message shouldNotContain "os-1" + restored.error!!.first.message shouldNotContain "ext-1" + } + } + } + + test("an absent LoginData payload is a failure rather than a blank success") { + val restored = OneSignalResult.fromMap(mapOf("success" to true), LoginData::fromMap) + + restored.isSuccess.shouldBeFalse() + restored.data.shouldBeNull() + restored.error!!.first.code shouldBe ErrorCode.UNKNOWN + } + + // JSONObject.NULL is what org.json puts under a JSON null. It is a live object, so a + // `!= null` check treats a successful envelope's `"error": null` as a present error. + test("JSONObject.NULL under error is treated as no error") { + val restored = + OneSignalResult.fromMap( + mapOf( + "success" to true, + "data" to mapOf("onesignalId" to "os-1", "externalId" to "ext-1"), + "error" to JSONObject.NULL, + ), + LoginData::fromMap, + ) + + restored.isSuccess.shouldBeTrue() + restored.data!!.onesignalId shouldBe "os-1" + restored.data!!.externalId shouldBe "ext-1" + restored.error.shouldBeNull() + } + + test("LoginData subscription IDs round-trip when present and stay absent when omitted") { + val withIds = + LoginData( + "os-1", + "ext-1", + emailSubscriptionId = "email-sub", + smsSubscriptionId = "sms-sub", + ) + val restoredWithIds = OneSignalResult.fromMap(OneSignalResult.success(withIds).toMap(), LoginData::fromMap) + + restoredWithIds.isSuccess.shouldBeTrue() + restoredWithIds.data!!.emailSubscriptionId shouldBe "email-sub" + restoredWithIds.data!!.smsSubscriptionId shouldBe "sms-sub" + restoredWithIds.toMap() shouldBe + mapOf( + "success" to true, + "data" to + mapOf( + "onesignalId" to "os-1", + "externalId" to "ext-1", + "emailSubscriptionId" to "email-sub", + "smsSubscriptionId" to "sms-sub", + ), + "error" to null, + ) + + val restoredWithoutIds = + OneSignalResult.fromMap( + mapOf("success" to true, "data" to mapOf("onesignalId" to "os-1", "externalId" to "ext-1")), + LoginData::fromMap, + ) + restoredWithoutIds.data!!.emailSubscriptionId.shouldBeNull() + restoredWithoutIds.data!!.smsSubscriptionId.shouldBeNull() + restoredWithoutIds.toMap()["data"] shouldBe mapOf("onesignalId" to "os-1", "externalId" to "ext-1") + } + + test("LoginData ignores empty or wrongly typed subscription IDs rather than failing the payload") { + val restored = + OneSignalResult.fromMap( + mapOf( + "success" to true, + "data" to + mapOf( + "onesignalId" to "os-1", + "externalId" to "ext-1", + "emailSubscriptionId" to "", + "smsSubscriptionId" to 1, + ), + ), + LoginData::fromMap, + ) + + restored.isSuccess.shouldBeTrue() + restored.data!!.emailSubscriptionId.shouldBeNull() + restored.data!!.smsSubscriptionId.shouldBeNull() + } + + test("JSONObject.NULL under data is treated as an absent payload") { + val restored = + OneSignalResult.fromMap( + mapOf("success" to true, "data" to JSONObject.NULL, "error" to JSONObject.NULL), + InitData::fromMap, + ) + + restored.isSuccess.shouldBeTrue() + restored.data.shouldNotBeNull() + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalSuspendMethodsExistTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalSuspendMethodsExistTest.kt index d277361055..6860a45341 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalSuspendMethodsExistTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalSuspendMethodsExistTest.kt @@ -148,6 +148,30 @@ class OneSignalSuspendMethodsExistTest : FunSpec({ assert(kFunction.parameters.size >= 2) { "loginSuspend should have at least 2 parameters (receiver + externalId)" } } + test("identity-only login still exists and is not suspend") { + val blocking = + OneSignal::class.memberFunctions + .filter { it.name == "login" && !it.isSuspend } + + assert(blocking.any { it.parameters.size == 2 }) { "login(externalId) is missing" } + assert(blocking.any { it.parameters.size == 3 }) { "login(externalId, jwtBearerToken) is missing" } + } + + test("suspend login with profile exists and returns OneSignalResult") { + val kFunction = + OneSignal::class.memberFunctions + .firstOrNull { fn -> + fn.name == "login" && + fn.isSuspend && + fn.parameters.any { it.type.classifier == OneSignalUserProfile::class } + } + + assert(kFunction != null) { "suspend login(externalId, profile) not found" } + assert(kFunction!!.returnType.classifier == OneSignalResult::class) { + "suspend login(externalId, profile) should return OneSignalResult" + } + } + test("logoutSuspend exists with no parameters") { val method: suspend () -> Unit = OneSignal::logoutSuspend diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalUserProfileTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalUserProfileTests.kt new file mode 100644 index 0000000000..ee541afa12 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalUserProfileTests.kt @@ -0,0 +1,101 @@ +package com.onesignal + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe + +class OneSignalUserProfileTests : FunSpec({ + + test("Kotlin defaults are all empty") { + val profile = OneSignalUserProfile() + + profile.email.shouldBeNull() + profile.sms.shouldBeNull() + profile.tags shouldBe emptyMap() + profile.aliases shouldBe emptyMap() + profile.hasFields shouldBe false + } + + test("Kotlin named args set only the fields that were passed") { + val profile = + OneSignalUserProfile( + email = "bob@example.com", + tags = mapOf("plan" to "pro"), + ) + + profile.email shouldBe "bob@example.com" + profile.sms.shouldBeNull() + profile.tags shouldBe mapOf("plan" to "pro") + profile.aliases shouldBe emptyMap() + profile.hasFields shouldBe true + } + + test("constructor copies tags so later mutation of the caller's map is not visible") { + val tags = mutableMapOf("plan" to "pro") + val profile = OneSignalUserProfile(tags = tags) + + tags["plan"] = "changed" + + profile.tags shouldBe mapOf("plan" to "pro") + } + + test("toString redacts email and sms") { + val profile = OneSignalUserProfile(email = "bob@example.com", sms = "+15555550100") + + profile.toString().contains("bob@example.com") shouldBe false + profile.toString().contains("+15555550100") shouldBe false + profile.toString().contains("") shouldBe true + } + + test("Java builder can set email without an sms") { + val profile = + OneSignalUserProfile.builder() + .setEmail("bob@example.com") + .build() + + profile.email shouldBe "bob@example.com" + profile.sms.shouldBeNull() + profile.tags shouldBe emptyMap() + profile.aliases shouldBe emptyMap() + } + + test("Java builder can set sms without an email") { + val profile = + OneSignalUserProfile.Builder() + .setSms("+15555550100") + .setTags(mapOf("plan" to "pro")) + .setAliases(mapOf("facebook" to "bob")) + .build() + + profile.email.shouldBeNull() + profile.sms shouldBe "+15555550100" + profile.tags shouldBe mapOf("plan" to "pro") + profile.aliases shouldBe mapOf("facebook" to "bob") + } + + test("builder copies tags so later mutation of the caller's map is not visible") { + val tags = mutableMapOf("plan" to "pro") + val profile = OneSignalUserProfile.builder().setTags(tags).build() + + tags["plan"] = "changed" + + profile.tags shouldBe mapOf("plan" to "pro") + } + + test("blank email and sms do not count as fields") { + OneSignalUserProfile(email = " ", sms = "").hasFields shouldBe false + } + + test("validationError is null for a valid profile") { + OneSignalUserProfile(email = "bob@example.com", sms = "+15555550100").validationError().shouldBeNull() + OneSignalUserProfile().validationError().shouldBeNull() + } + + test("validationError rejects a malformed email") { + OneSignalUserProfile(email = "not-an-email").validationError() shouldBe "Invalid email address" + } + + test("validationError rejects a non-E.164 sms") { + OneSignalUserProfile(sms = "555-0100").validationError() shouldBe "Invalid SMS number" + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt index 462ea7a746..38a9cb9b67 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt @@ -3,6 +3,7 @@ package com.onesignal.core.internal.application import android.content.Context import androidx.test.core.app.ApplicationProvider.getApplicationContext import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.OneSignalUserProfile import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.internal.OneSignalImp @@ -291,6 +292,19 @@ class SDKInitSuspendTests : FunSpec({ } } + test("composite login should throw exception when initWithContext is never called") { + val oneSignalImp = OneSignalImp() + + runBlocking { + val exception = + shouldThrow { + oneSignalImp.login("testUser", OneSignalUserProfile()) + } + + exception.message shouldBe "Must call 'initWithContext' before 'login'" + } + } + test("logoutSuspend should throw exception when initWithContext is never called") { // Given val oneSignalImp = OneSignalImp() diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt index f6ec6a2740..7b67d3ccdc 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt @@ -1,5 +1,6 @@ package com.onesignal.core.internal.operations +import com.onesignal.OneSignalUserProfile import com.onesignal.common.threading.Waiter import com.onesignal.common.threading.WaiterWithValue import com.onesignal.core.internal.operations.impl.OperationModelStore @@ -15,8 +16,13 @@ import com.onesignal.mocks.MockHelper import com.onesignal.mocks.MockPreferencesService import com.onesignal.user.internal.jwt.JwtRequirement import com.onesignal.user.internal.jwt.JwtTokenStore +import com.onesignal.user.internal.operations.CreateSubscriptionOperation import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getNewRecordState import com.onesignal.user.internal.operations.LoginUserOperation +import com.onesignal.user.internal.operations.impl.executors.LoginUserOperationExecutor +import com.onesignal.user.internal.operations.impl.executors.SubscriptionOperationExecutor +import com.onesignal.user.internal.subscriptions.SubscriptionStatus +import com.onesignal.user.internal.subscriptions.SubscriptionType import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.ints.shouldBeGreaterThan import io.kotest.matchers.ints.shouldBeLessThan @@ -188,6 +194,71 @@ class OperationRepoTests : FunSpec({ merged.existingOnesignalId shouldBe "anon-uuid" } + test("enqueue dedupes LoginUserOperation for same onesignalId and merges profile fields") { + val mocks = Mocks() + val operationRepo = mocks.operationRepo + + val queuedOp = LoginUserOperation("appId", "local-new", "alice", null) + queuedOp.id = UUID.randomUUID().toString() + synchronized(operationRepo.queue) { + operationRepo.queue.add(OperationQueueItem(queuedOp, bucket = 0)) + } + + val incomingOp = + LoginUserOperation( + "appId", + "local-new", + "alice", + null, + OneSignalUserProfile( + email = "a@b.com", + tags = mapOf("plan" to "pro"), + ), + ) + + operationRepo.enqueue(incomingOp) + mocks.waitForInternalEnqueue() + + operationRepo.queue.size shouldBe 1 + val merged = operationRepo.queue.first().operation as LoginUserOperation + merged.email shouldBe "a@b.com" + merged.tags shouldBe mapOf("plan" to "pro") + } + + test("enqueue dedupe last-write-wins for incoming email and phone") { + val mocks = Mocks() + val operationRepo = mocks.operationRepo + + val queuedOp = + LoginUserOperation( + "appId", + "local-new", + "alice", + null, + OneSignalUserProfile(email = "first@b.com", sms = "+15555550100"), + ) + queuedOp.id = UUID.randomUUID().toString() + synchronized(operationRepo.queue) { + operationRepo.queue.add(OperationQueueItem(queuedOp, bucket = 0)) + } + + val incomingOp = + LoginUserOperation( + "appId", + "local-new", + "alice", + null, + OneSignalUserProfile(email = "second@b.com", sms = "+15555550999"), + ) + + operationRepo.enqueue(incomingOp) + mocks.waitForInternalEnqueue() + + val merged = operationRepo.queue.first().operation as LoginUserOperation + merged.email shouldBe "second@b.com" + merged.sms shouldBe "+15555550999" + } + test("enqueue dedupe does not merge a local existingOnesignalId onto the queued op") { // Regression: when upgrading from 5.0.0-5.1.7, an anon user's backend-create // may have been dropped, so its onesignalId is still "local-". If @@ -218,11 +289,6 @@ class OperationRepoTests : FunSpec({ } test("enqueue dedupe wakes both queued and incoming enqueueAndWait callers on SUCCESS") { - // A LoginUserOperation is queued via enqueueAndWait while the loop is not yet - // started, so it sits in the queue with its waiter attached. A second - // enqueueAndWait arrives for the same onesignalId. Dedupe wakes the incoming - // caller immediately with true; the queued op's waiter wakes with the real - // execution result when SUCCESS lands. val mocks = Mocks() val opRepo = mocks.operationRepo val executeOperationsCall = mockExecuteOperations(opRepo) @@ -230,28 +296,239 @@ class OperationRepoTests : FunSpec({ val queuedOp = LoginUserOperation("appId", "alice", "ext", "anon-uuid") val incomingOp = LoginUserOperation("appId", "alice", "ext", "anon-uuid") - // When — first enqueueAndWait runs (loop not started, op stays in queue). - // UNDISPATCHED so enqueueAndWait reaches its scope.launch + suspend before - // we send the incoming op below; otherwise the scope's single thread would - // see the incoming op's internalEnqueue first and dedupe direction reverses. val queuedDone = WaiterWithValue() launch(start = CoroutineStart.UNDISPATCHED) { queuedDone.wake(opRepo.enqueueAndWait(queuedOp)) } mocks.waitForInternalEnqueue() - // Incoming dedupes against the queued op and is woken immediately - val incomingResult = withTimeout(1_000) { opRepo.enqueueAndWait(incomingOp) } - // Run the loop; mocked executor SUCCESS wakes the queued op's waiter + val incomingDone = WaiterWithValue() + launch(start = CoroutineStart.UNDISPATCHED) { + incomingDone.wake(opRepo.enqueueAndWait(incomingOp)) + } + mocks.waitForInternalEnqueue() + opRepo.start() executeOperationsCall.waitForWake() + val incomingResult = withTimeout(1_000) { incomingDone.waitForWake() } val queuedResult = withTimeout(1_000) { queuedDone.waitForWake() } - // Then incomingResult shouldBe true queuedResult shouldBe true } + test("enqueue dedupe wakes both waiters with the real FAIL_NORETRY result") { + val mocks = Mocks() + val opRepo = mocks.operationRepo + val executeOperationsCall = mockExecuteOperations(opRepo, OperationWaitResult(false, 400, "bad phone")) + + val queuedOp = LoginUserOperation("appId", "alice", "ext", null, OneSignalUserProfile(sms = "+1")) + val incomingOp = LoginUserOperation("appId", "alice", "ext", null, OneSignalUserProfile(email = "a@b.com")) + + val queuedDone = WaiterWithValue() + launch(start = CoroutineStart.UNDISPATCHED) { + queuedDone.wake(opRepo.enqueueAndAwaitResult(queuedOp)) + } + mocks.waitForInternalEnqueue() + + val incomingDone = WaiterWithValue() + launch(start = CoroutineStart.UNDISPATCHED) { + incomingDone.wake(opRepo.enqueueAndAwaitResult(incomingOp)) + } + mocks.waitForInternalEnqueue() + + opRepo.start() + executeOperationsCall.waitForWake() + val incomingResult = withTimeout(1_000) { incomingDone.waitForWake() } + val queuedResult = withTimeout(1_000) { queuedDone.waitForWake() } + + incomingResult.success shouldBe false + incomingResult.httpStatusCode shouldBe 400 + queuedResult.success shouldBe false + queuedResult.httpStatusCode shouldBe 400 + } + + test("FAIL_NORETRY on login keeps grouped create-subscription in the queue") { + val mocks = Mocks() + every { mocks.executor.operations } returns + listOf( + LoginUserOperationExecutor.LOGIN_USER, + SubscriptionOperationExecutor.CREATE_SUBSCRIPTION, + ) + coEvery { mocks.executor.execute(any()) } returns + ExecutionResponse(ExecutionResult.FAIL_NORETRY, httpStatusCode = 400) + + val opRepo = mocks.operationRepo + val loginOp = LoginUserOperation("appId", "local-alice", "alice", null) + val createOp = + CreateSubscriptionOperation( + "appId", + "local-alice", + "alice", + "local-push", + SubscriptionType.PUSH, + true, + "push-token", + SubscriptionStatus.SUBSCRIBED, + ) + + val loginDone = WaiterWithValue() + launch(start = CoroutineStart.UNDISPATCHED) { + loginDone.wake(opRepo.enqueueAndAwaitResult(loginOp)) + } + mocks.waitForInternalEnqueue() + opRepo.enqueue(createOp) + mocks.waitForInternalEnqueue() + + opRepo.start() + val loginResult = withTimeout(2_000) { loginDone.waitForWake() } + + loginResult.success shouldBe false + opRepo.containsInstanceOf() shouldBe false + mocks.operationModelStore.list().none { it is LoginUserOperation } shouldBe true + opRepo.queue.map { it.operation } shouldBe listOf(createOp) + verify { mocks.operationModelStore.remove(loginOp.id) } + verify(exactly = 0) { mocks.operationModelStore.remove(createOp.id) } + coVerify { + mocks.executor.execute( + match { + it.size == 2 && + it[0] is LoginUserOperation && + it[1] is CreateSubscriptionOperation + }, + ) + } + } + + test("FAIL_NORETRY on login does not pause so a later login can execute") { + val mocks = Mocks() + every { mocks.executor.operations } returns listOf(LoginUserOperationExecutor.LOGIN_USER) + coEvery { mocks.executor.execute(any()) } returns + ExecutionResponse(ExecutionResult.FAIL_NORETRY, httpStatusCode = 409, httpResponse = "user-1") andThen + ExecutionResponse(ExecutionResult.SUCCESS) + + val opRepo = mocks.operationRepo + opRepo.start() + val first = + withTimeout(2_000) { + opRepo.enqueueAndAwaitResult( + LoginUserOperation( + "appId", + "local-alice", + "alice", + null, + OneSignalUserProfile(aliases = mapOf("facebook" to "bob")), + ), + ) + } + first.success shouldBe false + first.httpStatusCode shouldBe 409 + + val second = + withTimeout(2_000) { + opRepo.enqueueAndAwaitResult(LoginUserOperation("appId", "local-alice", "alice", null)) + } + second.success shouldBe true + coVerify(exactly = 2) { mocks.executor.execute(any()) } + } + + test("enqueueAndAwaitResult same login after FAIL_PAUSE returns false without hanging") { + val mocks = Mocks() + every { mocks.executor.operations } returns listOf(LoginUserOperationExecutor.LOGIN_USER) + coEvery { mocks.executor.execute(any()) } returns ExecutionResponse(ExecutionResult.FAIL_PAUSE_OPREPO) + + val opRepo = mocks.operationRepo + opRepo.start() + val first = + withTimeout(2_000) { + opRepo.enqueueAndAwaitResult(LoginUserOperation("appId", "local-alice", "alice", null)) + } + first.success shouldBe false + + val second = + withTimeout(500) { + opRepo.enqueueAndAwaitResult(LoginUserOperation("appId", "local-alice", "alice", null)) + } + second.success shouldBe false + coVerify(exactly = 1) { mocks.executor.execute(any()) } + } + + test("enqueue dedupes LoginUserOperation that is already in-flight") { + val mocks = Mocks() + every { mocks.executor.operations } returns listOf(LoginUserOperationExecutor.LOGIN_USER) + val executeStarted = Waiter() + val releaseExecute = Waiter() + coEvery { mocks.executor.execute(any()) } coAnswers { + executeStarted.wake() + releaseExecute.waitForWake() + ExecutionResponse(ExecutionResult.SUCCESS) + } + + val opRepo = mocks.operationRepo + opRepo.start() + val firstDone = WaiterWithValue() + launch(start = CoroutineStart.UNDISPATCHED) { + firstDone.wake( + opRepo.enqueueAndAwaitResult(LoginUserOperation("appId", "local-alice", "alice", null)), + ) + } + executeStarted.waitForWake() + + val secondDone = WaiterWithValue() + launch(start = CoroutineStart.UNDISPATCHED) { + secondDone.wake( + opRepo.enqueueAndAwaitResult(LoginUserOperation("appId", "local-alice", "alice", null)), + ) + } + verify(exactly = 2, timeout = 1_000) { + opRepo["internalEnqueue"]( + any(), any(), any(), any() + ) + } + + releaseExecute.wake() + val firstResult = withTimeout(2_000) { firstDone.waitForWake() } + val secondResult = withTimeout(2_000) { secondDone.waitForWake() } + + firstResult.success shouldBe true + secondResult.success shouldBe true + coVerify(exactly = 1) { mocks.executor.execute(any()) } + } + + test("duplicate login during post-create delay wakes with the completed result") { + val mocks = Mocks() + every { mocks.executor.operations } returns listOf(LoginUserOperationExecutor.LOGIN_USER) + coEvery { mocks.executor.execute(any()) } returns + ExecutionResponse( + ExecutionResult.SUCCESS, + idTranslations = mapOf("local-alice" to "backend-alice"), + ) + + val opRepo = mocks.operationRepo + val delayStarted = Waiter() + val releaseDelay = Waiter() + coEvery { opRepo.delayForPostCreate(any()) } coAnswers { + delayStarted.wake() + releaseDelay.waitForWake() + } + + opRepo.start() + val first = + withTimeout(2_000) { + opRepo.enqueueAndAwaitResult(LoginUserOperation("appId", "local-alice", "alice", null)) + } + first.success shouldBe true + delayStarted.waitForWake() + + val second = + withTimeout(1_000) { + opRepo.enqueueAndAwaitResult(LoginUserOperation("appId", "local-alice", "alice", null)) + } + second.success shouldBe true + coVerify(exactly = 1) { mocks.executor.execute(any()) } + releaseDelay.wake() + } + test("containsInstanceOf") { // Given val mocks = Mocks() @@ -1132,6 +1409,46 @@ class OperationRepoTests : FunSpec({ verify(exactly = 0) { mocks.operationModelStore.remove(opId) } } + test("FAIL_NORETRY wakes enqueueAndAwaitResult with HTTP status and body") { + val mocks = Mocks() + coEvery { mocks.executor.execute(any()) } returns + ExecutionResponse( + ExecutionResult.FAIL_NORETRY, + httpStatusCode = 400, + httpResponse = """{"errors":["invalid phone"]}""", + ) + + mocks.operationRepo.start() + val result = + runBlocking { + withTimeout(2_000) { + mocks.operationRepo.enqueueAndAwaitResult(mockOperation()) + } + } + + result.success shouldBe false + result.httpStatusCode shouldBe 400 + result.httpResponse shouldBe """{"errors":["invalid phone"]}""" + } + + test("enqueueAndAwaitResult returns failure when the await timeout elapses") { + val mocks = Mocks() + mocks.configModelStore.model.opRepoAwaitTimeout = 50 + coEvery { mocks.executor.execute(any()) } coAnswers { + delay(10_000) + ExecutionResponse(ExecutionResult.SUCCESS) + } + + mocks.operationRepo.start() + val result = + withTimeout(1_000) { + mocks.operationRepo.enqueueAndAwaitResult(mockOperation()) + } + + result.success shouldBe false + result.httpStatusCode shouldBe null + } + test("FAIL_UNAUTHORIZED with IV inactive falls back to default drop-on-fail") { val mocks = Mocks() mocks.identityVerificationService = CoreInternalMocks.identityVerificationService( @@ -1193,11 +1510,16 @@ class OperationRepoTests : FunSpec({ private fun mockOperationNonGroupable() = mockOperation(groupComparisonType = GroupComparisonType.NONE) - private fun mockExecuteOperations(opRepo: OperationRepo): WaiterWithValue { + private fun mockExecuteOperations( + opRepo: OperationRepo, + waitResult: OperationWaitResult = OperationWaitResult(true), + ): WaiterWithValue { val executeWaiter = WaiterWithValue() coEvery { opRepo.executeOperations(any()) } coAnswers { executeWaiter.wake(true) - firstArg>().forEach { it.waiter?.wake(true) } + firstArg>().forEach { + it.wakeWaiters(waitResult) + } } return executeWaiter } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt index 8b60c18648..e5fafaba73 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt @@ -1,7 +1,11 @@ package com.onesignal.user.internal +import com.onesignal.OneSignalUserProfile +import com.onesignal.common.PIIHasher import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.operations.IOperationRepo +import com.onesignal.core.internal.operations.LoginWaitMetadata +import com.onesignal.core.internal.operations.OperationWaitResult import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.mocks.MockHelper @@ -11,6 +15,9 @@ import com.onesignal.user.internal.jwt.JwtRequirement import com.onesignal.user.internal.jwt.JwtTokenStore import com.onesignal.user.internal.operations.LoginUserOperation import com.onesignal.user.internal.properties.PropertiesModel +import com.onesignal.user.internal.subscriptions.SubscriptionModel +import com.onesignal.user.internal.subscriptions.SubscriptionModelStore +import com.onesignal.user.internal.subscriptions.SubscriptionType import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.mockk.coEvery @@ -62,17 +69,18 @@ class LoginHelperTests : FunSpec({ configModel = mockConfigModel, jwtTokenStore = JwtTokenStore(MockPreferencesService()), lock = loginLock, + subscriptionModelStore = mockk(relaxed = true), ) // When runBlocking { - val context = loginHelper.switchUser(currentExternalId) + val context = loginHelper.switchUser(currentExternalId).context if (context != null) loginHelper.enqueueLogin(context) } // Then - should return early without any operations verify(exactly = 0) { mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = any(), modify = any()) } - coVerify(exactly = 0) { mockOperationRepo.enqueueAndWait(any()) } + coVerify(exactly = 0) { mockOperationRepo.enqueueAndAwaitResult(any()) } } test("login with different external id creates and switches to new user") { @@ -107,7 +115,7 @@ class LoginHelperTests : FunSpec({ every { mockIdentityModelStore.model } returns newIdentityModel } - coEvery { mockOperationRepo.enqueueAndWait(any()) } returns true + coEvery { mockOperationRepo.enqueueAndAwaitResult(any()) } returns OperationWaitResult(true) val loginHelper = LoginHelper( @@ -117,11 +125,12 @@ class LoginHelperTests : FunSpec({ configModel = mockConfigModel, jwtTokenStore = JwtTokenStore(MockPreferencesService()), lock = loginLock, + subscriptionModelStore = mockk(relaxed = true), ) // When runBlocking { - val context = loginHelper.switchUser(newExternalId) + val context = loginHelper.switchUser(newExternalId).context if (context != null) loginHelper.enqueueLogin(context) } @@ -132,7 +141,7 @@ class LoginHelperTests : FunSpec({ newIdentityModel.externalId shouldBe newExternalId coVerify(exactly = 1) { - mockOperationRepo.enqueueAndWait( + mockOperationRepo.enqueueAndAwaitResult( withArg { operation -> operation.appId shouldBe appId operation.onesignalId shouldBe newOneSignalId @@ -175,7 +184,7 @@ class LoginHelperTests : FunSpec({ every { mockIdentityModelStore.model } returns newIdentityModel } - coEvery { mockOperationRepo.enqueueAndWait(any()) } returns true + coEvery { mockOperationRepo.enqueueAndAwaitResult(any()) } returns OperationWaitResult(true) val loginHelper = LoginHelper( @@ -185,17 +194,18 @@ class LoginHelperTests : FunSpec({ configModel = mockConfigModel, jwtTokenStore = JwtTokenStore(MockPreferencesService()), lock = loginLock, + subscriptionModelStore = mockk(relaxed = true), ) // When runBlocking { - val context = loginHelper.switchUser(newExternalId) + val context = loginHelper.switchUser(newExternalId).context if (context != null) loginHelper.enqueueLogin(context) } // Then - should provide existing OneSignal ID for anonymous user conversion coVerify(exactly = 1) { - mockOperationRepo.enqueueAndWait( + mockOperationRepo.enqueueAndAwaitResult( withArg { operation -> operation.appId shouldBe appId operation.onesignalId shouldBe newOneSignalId @@ -239,7 +249,7 @@ class LoginHelperTests : FunSpec({ every { mockIdentityModelStore.model } returns newIdentityModel } - coEvery { mockOperationRepo.enqueueAndWait(any()) } returns true + coEvery { mockOperationRepo.enqueueAndAwaitResult(any()) } returns OperationWaitResult(true) val loginHelper = LoginHelper( @@ -249,17 +259,18 @@ class LoginHelperTests : FunSpec({ configModel = mockConfigModel, jwtTokenStore = JwtTokenStore(MockPreferencesService()), lock = Any(), + subscriptionModelStore = mockk(relaxed = true), ) // When runBlocking { - val context = loginHelper.switchUser(newExternalId, jwtBearerToken = "fresh-jwt") + val context = loginHelper.switchUser(newExternalId, jwtBearerToken = "fresh-jwt").context if (context != null) loginHelper.enqueueLogin(context) } // Then — under IV, the executor must take the createUser (upsert) path; no merge link. coVerify(exactly = 1) { - mockOperationRepo.enqueueAndWait( + mockOperationRepo.enqueueAndAwaitResult( withArg { operation -> operation.externalId shouldBe newExternalId operation.existingOnesignalId shouldBe null @@ -301,7 +312,8 @@ class LoginHelperTests : FunSpec({ } // Mock operation failure - coEvery { mockOperationRepo.enqueueAndWait(any()) } returns false + coEvery { mockOperationRepo.enqueueAndAwaitResult(any()) } returns + OperationWaitResult(false, httpStatusCode = 400, httpResponse = """{"errors":["invalid phone"]}""") val loginHelper = LoginHelper( @@ -311,17 +323,22 @@ class LoginHelperTests : FunSpec({ configModel = mockConfigModel, jwtTokenStore = JwtTokenStore(MockPreferencesService()), lock = loginLock, + subscriptionModelStore = mockk(relaxed = true), ) // When - runBlocking { - val context = loginHelper.switchUser(newExternalId) - if (context != null) loginHelper.enqueueLogin(context) - } + val waitResult = + runBlocking { + val context = loginHelper.switchUser(newExternalId).context + loginHelper.enqueueLogin(context!!) + } - // Then - should still switch users but operation fails + // Then - should still switch users but operation fails with the backend body + waitResult.success shouldBe false + waitResult.httpStatusCode shouldBe 400 + waitResult.httpResponse shouldBe """{"errors":["invalid phone"]}""" verify(exactly = 1) { mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = any(), modify = any()) } - coVerify(exactly = 1) { mockOperationRepo.enqueueAndWait(any()) } + coVerify(exactly = 1) { mockOperationRepo.enqueueAndAwaitResult(any()) } } test("login with JWT stores token in JwtTokenStore before enqueueing op") { @@ -343,7 +360,7 @@ class LoginHelperTests : FunSpec({ mockIdentityModelStore.model.externalId = newExternalId } val mockOperationRepo = mockk(relaxed = true) - coEvery { mockOperationRepo.enqueueAndWait(any()) } returns true + coEvery { mockOperationRepo.enqueueAndAwaitResult(any()) } returns OperationWaitResult(true) val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED @@ -357,11 +374,12 @@ class LoginHelperTests : FunSpec({ configModel = mockConfigModel, jwtTokenStore = jwtTokenStore, lock = Any(), + subscriptionModelStore = mockk(relaxed = true), ) // When runBlocking { - val context = loginHelper.switchUser(newExternalId, jwtBearerToken = "the-jwt") + val context = loginHelper.switchUser(newExternalId, jwtBearerToken = "the-jwt").context if (context != null) loginHelper.enqueueLogin(context) } @@ -392,11 +410,12 @@ class LoginHelperTests : FunSpec({ configModel = mockConfigModel, jwtTokenStore = jwtTokenStore, lock = Any(), + subscriptionModelStore = mockk(relaxed = true), ) // When: login with same externalId but new JWT runBlocking { - val context = loginHelper.switchUser(currentExternalId, jwtBearerToken = "new-jwt") + val context = loginHelper.switchUser(currentExternalId, jwtBearerToken = "new-jwt").context if (context != null) loginHelper.enqueueLogin(context) } @@ -407,6 +426,286 @@ class LoginHelperTests : FunSpec({ verify(exactly = 1) { mockOperationRepo.forceExecuteOperations() } } + test("enqueueLogin copies profile fields onto LoginUserOperation") { + val mockIdentityModelStore = + MockHelper.identityModelStore { model -> + model.externalId = currentExternalId + model.onesignalId = currentOneSignalId + } + val newIdentityModel = + IdentityModel().apply { + externalId = newExternalId + onesignalId = newOneSignalId + } + val mockUserSwitcher = mockk() + val mockOperationRepo = mockk() + val mockConfigModel = mockk() + every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED + val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() + every { + mockUserSwitcher.createAndSwitchToNewUser( + suppressBackendOperation = any(), + modify = capture(userSwitcherSlot), + ) + } answers { + userSwitcherSlot.captured(newIdentityModel, PropertiesModel()) + every { mockIdentityModelStore.model } returns newIdentityModel + } + coEvery { mockOperationRepo.enqueueAndAwaitResult(any()) } returns OperationWaitResult(true) + + val loginHelper = + LoginHelper( + identityModelStore = mockIdentityModelStore, + userSwitcher = mockUserSwitcher, + operationRepo = mockOperationRepo, + configModel = mockConfigModel, + jwtTokenStore = JwtTokenStore(MockPreferencesService()), + lock = Any(), + subscriptionModelStore = mockk(relaxed = true), + ) + + runBlocking { + val context = loginHelper.switchUser(newExternalId).context!! + loginHelper.enqueueLogin(context, OneSignalUserProfile(email = "a@b.com", tags = mapOf("plan" to "pro"))) + } + + coVerify(exactly = 1) { + mockOperationRepo.enqueueAndAwaitResult( + withArg { operation -> + operation.email shouldBe "a@b.com" + operation.tags shouldBe mapOf("plan" to "pro") + operation.externalId shouldBe newExternalId + }, + ) + } + } + + test("same external id with a profile still enqueues login") { + val mockIdentityModelStore = + MockHelper.identityModelStore { model -> + model.externalId = currentExternalId + model.onesignalId = currentOneSignalId + } + val mockUserSwitcher = mockk(relaxed = true) + val mockOperationRepo = mockk() + val mockConfigModel = mockk() + every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED + coEvery { mockOperationRepo.enqueueAndAwaitResult(any()) } returns OperationWaitResult(true) + + val loginHelper = + LoginHelper( + identityModelStore = mockIdentityModelStore, + userSwitcher = mockUserSwitcher, + operationRepo = mockOperationRepo, + configModel = mockConfigModel, + jwtTokenStore = JwtTokenStore(MockPreferencesService()), + lock = Any(), + subscriptionModelStore = mockk(relaxed = true), + ) + + runBlocking { + val context = + loginHelper.switchUser(currentExternalId, profile = OneSignalUserProfile(email = "a@b.com")).context + loginHelper.enqueueLogin(context!!, OneSignalUserProfile(email = "a@b.com")) + } + + verify(exactly = 0) { mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = any(), modify = any()) } + coVerify(exactly = 1) { + mockOperationRepo.enqueueAndAwaitResult( + withArg { operation -> + operation.onesignalId shouldBe currentOneSignalId + operation.externalId shouldBe currentExternalId + operation.existingOnesignalId shouldBe null + operation.email shouldBe "a@b.com" + }, + ) + } + } + + test("loginDataFromStores fills email and SMS ids from matching addresses") { + val identityStore = + MockHelper.identityModelStore { model -> + model.externalId = currentExternalId + model.onesignalId = currentOneSignalId + } + val subscriptions = SubscriptionModelStore(MockPreferencesService()) + subscriptions.add( + SubscriptionModel().apply { + id = "email-id" + type = SubscriptionType.EMAIL + address = "a@b.com" + }, + ) + subscriptions.add( + SubscriptionModel().apply { + id = "sms-id" + type = SubscriptionType.SMS + address = "+15555550100" + }, + ) + val loginHelper = + LoginHelper( + identityModelStore = identityStore, + userSwitcher = mockk(relaxed = true), + operationRepo = mockk(relaxed = true), + configModel = mockk(relaxed = true), + jwtTokenStore = JwtTokenStore(MockPreferencesService()), + lock = Any(), + subscriptionModelStore = subscriptions, + ) + + val data = + loginHelper.loginDataFromStores( + currentExternalId, + OneSignalUserProfile(email = "a@b.com", sms = "+15555550100"), + ) + + data.onesignalId shouldBe currentOneSignalId + data.externalId shouldBe currentExternalId + data.emailSubscriptionId shouldBe "email-id" + data.smsSubscriptionId shouldBe "sms-id" + loginHelper.loginDataFromStores(currentExternalId, OneSignalUserProfile()).emailSubscriptionId shouldBe null + } + + test("same external id with a local onesignalId re-enqueues without switching") { + val localId = "local-retry-id" + val mockIdentityModelStore = + MockHelper.identityModelStore { model -> + model.externalId = currentExternalId + model.onesignalId = localId + } + val mockUserSwitcher = mockk(relaxed = true) + val mockOperationRepo = mockk(relaxed = true) + val mockConfigModel = mockk() + every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED + + val loginHelper = + LoginHelper( + identityModelStore = mockIdentityModelStore, + userSwitcher = mockUserSwitcher, + operationRepo = mockOperationRepo, + configModel = mockConfigModel, + jwtTokenStore = JwtTokenStore(MockPreferencesService()), + lock = Any(), + subscriptionModelStore = mockk(relaxed = true), + ) + + val switched = loginHelper.switchUser(currentExternalId) + + switched.context?.newIdentityOneSignalId shouldBe localId + switched.context?.externalId shouldBe currentExternalId + switched.onesignalId shouldBe localId + verify(exactly = 0) { mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = any(), modify = any()) } + } + + test("loginData prefers wait-result ids over the live stores") { + val identityStore = + MockHelper.identityModelStore { model -> + model.externalId = currentExternalId + model.onesignalId = "stale-local" + } + val loginHelper = + LoginHelper( + identityModelStore = identityStore, + userSwitcher = mockk(relaxed = true), + operationRepo = mockk(relaxed = true), + configModel = mockk(relaxed = true), + jwtTokenStore = JwtTokenStore(MockPreferencesService()), + lock = Any(), + subscriptionModelStore = mockk(relaxed = true), + ) + + val data = + loginHelper.loginData( + currentExternalId, + OneSignalUserProfile(email = "Bob@Example.com"), + OperationWaitResult( + true, + metadata = LoginWaitMetadata(onesignalId = "backend-id", emailSubscriptionId = "email-from-wait"), + ), + fallbackOnesignalId = "fallback-id", + ) + + data.onesignalId shouldBe "backend-id" + data.emailSubscriptionId shouldBe "email-from-wait" + } + + test("loginDataFromStores matches email addresses case-insensitively") { + val identityStore = + MockHelper.identityModelStore { model -> + model.externalId = currentExternalId + model.onesignalId = currentOneSignalId + } + val subscriptions = SubscriptionModelStore(MockPreferencesService()) + subscriptions.add( + SubscriptionModel().apply { + id = "email-id" + type = SubscriptionType.EMAIL + address = "bob@example.com" + }, + ) + val loginHelper = + LoginHelper( + identityModelStore = identityStore, + userSwitcher = mockk(relaxed = true), + operationRepo = mockk(relaxed = true), + configModel = mockk(relaxed = true), + jwtTokenStore = JwtTokenStore(MockPreferencesService()), + lock = Any(), + subscriptionModelStore = subscriptions, + ) + + loginHelper.loginDataFromStores( + currentExternalId, + OneSignalUserProfile(email = "Bob@Example.com"), + ).emailSubscriptionId shouldBe "email-id" + } + + test("loginDataFromStores matches a hashed email or SMS address") { + val identityStore = + MockHelper.identityModelStore { model -> + model.externalId = currentExternalId + model.onesignalId = currentOneSignalId + } + val subscriptions = SubscriptionModelStore(MockPreferencesService()) + subscriptions.add( + SubscriptionModel().apply { + id = "email-id" + type = SubscriptionType.EMAIL + address = PIIHasher.hash("a@b.com") + }, + ) + subscriptions.add( + SubscriptionModel().apply { + id = "sms-id" + type = SubscriptionType.SMS + address = PIIHasher.hash("+15555550100") + }, + ) + val loginHelper = + LoginHelper( + identityModelStore = identityStore, + userSwitcher = mockk(relaxed = true), + operationRepo = mockk(relaxed = true), + configModel = mockk(relaxed = true), + jwtTokenStore = JwtTokenStore(MockPreferencesService()), + lock = Any(), + subscriptionModelStore = subscriptions, + ) + + val data = + loginHelper.loginDataFromStores( + currentExternalId, + OneSignalUserProfile(email = "a@b.com", sms = "+15555550100"), + ) + + data.emailSubscriptionId shouldBe "email-id" + data.smsSubscriptionId shouldBe "sms-id" + } + test("login with same externalId + empty JWT keeps the stored token") { val mockIdentityModelStore = MockHelper.identityModelStore { model -> @@ -428,9 +727,10 @@ class LoginHelperTests : FunSpec({ configModel = mockConfigModel, jwtTokenStore = jwtTokenStore, lock = Any(), + subscriptionModelStore = mockk(relaxed = true), ) - loginHelper.switchUser(currentExternalId, jwtBearerToken = "") shouldBe null + loginHelper.switchUser(currentExternalId, jwtBearerToken = "").context shouldBe null jwtTokenStore.getJwt(currentExternalId) shouldBe "old-jwt" verify(exactly = 0) { mockOperationRepo.forceExecuteOperations() } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/UserBackendServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/UserBackendServiceTests.kt index 110c7566cb..eacad4ecac 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/UserBackendServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/UserBackendServiceTests.kt @@ -25,7 +25,7 @@ class UserBackendServiceTests : FunSpec({ coEvery { spyHttpClient.post(any(), any(), any()) } returns HttpResponse(403, "FORBIDDEN") val userBackendService = UserBackendService(spyHttpClient) val identities = mapOf() - val properties = mapOf() + val properties = PropertiesObject() val subscriptions = listOf() // When @@ -48,7 +48,7 @@ class UserBackendServiceTests : FunSpec({ } returns HttpResponse(202, "{identity:{onesignal_id: \"$osId\", aliasLabel1: \"aliasValue1\"}, properties:{timezone_id: \"testTimeZone\", language: \"testLanguage\"}}") val userBackendService = UserBackendService(spyHttpClient) val identities = mapOf("aliasLabel1" to "aliasValue1") - val properties = mapOf("timezone_id" to "testTimeZone", "language" to "testLanguage") + val properties = PropertiesObject(timezoneId = "testTimeZone", language = "testLanguage") val subscriptions = listOf() // When @@ -68,6 +68,7 @@ class UserBackendServiceTests : FunSpec({ it.getJSONObject("identity").has("aliasLabel1") shouldBe true it.getJSONObject("identity").getString("aliasLabel1") shouldBe "aliasValue1" it.has("properties") shouldBe true + it.getJSONObject("properties").has("tags") shouldBe false it.has("subscriptions") shouldBe false }, any(), @@ -75,6 +76,33 @@ class UserBackendServiceTests : FunSpec({ } } + test("create user with tags nests them under properties") { + val osId = "11111111-1111-1111-1111-111111111111" + val spyHttpClient = mockk() + coEvery { + spyHttpClient.post(any(), any(), any()) + } returns HttpResponse(202, "{identity:{onesignal_id: \"$osId\"}, properties:{timezone_id: \"testTimeZone\"}}") + val userBackendService = UserBackendService(spyHttpClient) + + userBackendService.createUser( + "appId", + mapOf("external_id" to "user-1"), + listOf(), + PropertiesObject(timezoneId = "testTimeZone", tags = mapOf("plan" to "pro")), + ) + + coVerify { + spyHttpClient.post( + "apps/appId/users", + withArg { + it.getJSONObject("identity").getString("external_id") shouldBe "user-1" + it.getJSONObject("properties").getJSONObject("tags").getString("plan") shouldBe "pro" + }, + any(), + ) + } + } + test("create user with a subscription creates a new user") { // Given val osId = "11111111-1111-1111-1111-111111111111" @@ -85,7 +113,7 @@ class UserBackendServiceTests : FunSpec({ val userBackendService = UserBackendService(spyHttpClient) val identities = mapOf() val subscriptions = mutableListOf() - val properties = mapOf("timezone_id" to "testTimeZone", "language" to "testLanguage") + val properties = PropertiesObject(timezoneId = "testTimeZone", language = "testLanguage") subscriptions.add(SubscriptionObject("SHOULDNOTUSE", SubscriptionObjectType.ANDROID_PUSH)) // When diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt index 7f56573999..908c39bc4c 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt @@ -1,12 +1,16 @@ package com.onesignal.user.internal.operations import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.OneSignalUserProfile +import com.onesignal.common.PIIHasher import com.onesignal.common.exceptions.BackendException import com.onesignal.core.internal.operations.ExecutionResponse import com.onesignal.core.internal.operations.ExecutionResult +import com.onesignal.core.internal.operations.LoginWaitMetadata import com.onesignal.core.internal.operations.Operation import com.onesignal.mocks.AndroidMockHelper import com.onesignal.mocks.MockHelper +import com.onesignal.mocks.MockPreferencesService import com.onesignal.user.internal.backend.CreateUserResponse import com.onesignal.user.internal.backend.IUserBackendService import com.onesignal.user.internal.backend.IdentityConstants @@ -14,11 +18,14 @@ import com.onesignal.user.internal.backend.PropertiesObject import com.onesignal.user.internal.backend.SubscriptionObject import com.onesignal.user.internal.backend.SubscriptionObjectType import com.onesignal.user.internal.identity.IdentityModel +import com.onesignal.user.internal.identity.IdentityModelStore import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getIdentityVerificationService import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getJwtTokenStore import com.onesignal.user.internal.operations.impl.executors.IdentityOperationExecutor import com.onesignal.user.internal.operations.impl.executors.LoginUserOperationExecutor +import com.onesignal.user.internal.operations.impl.executors.matchingBackendSubscription import com.onesignal.user.internal.properties.PropertiesModel +import com.onesignal.user.internal.properties.PropertiesModelStore import com.onesignal.user.internal.subscriptions.SubscriptionModel import com.onesignal.user.internal.subscriptions.SubscriptionModelStore import com.onesignal.user.internal.subscriptions.SubscriptionStatus @@ -164,7 +171,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val response = loginUserOperationExecutor.execute(operations) // Then - response.result shouldBe ExecutionResult.FAIL_PAUSE_OPREPO + response.result shouldBe ExecutionResult.FAIL_NORETRY coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any()) } } @@ -224,6 +231,46 @@ class LoginUserOperationExecutorTests : FunSpec({ coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any()) } } + test("a null byte login with an existing user and a grouped subscription creates without SetAlias") { + val badExternalId = "\u0000: 1" + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) + val mockIdentityOperationExecutor = mockk() + val mockIdentityModelStore = + MockHelper.identityModelStore { + it.onesignalId = localOneSignalId + it.externalId = badExternalId + } + val loginUserOperationExecutor = + LoginUserOperationExecutor( + mockIdentityOperationExecutor, + AndroidMockHelper.applicationService(), + MockHelper.deviceService(), + mockUserBackendService, + mockIdentityModelStore, + MockHelper.propertiesModelStore(), + mockk(), + MockHelper.configModelStore(), + MockHelper.languageContext(), + getJwtTokenStore(), + getIdentityVerificationService(), + mockk(relaxed = true), + ) + + val response = + loginUserOperationExecutor.execute( + listOf( + LoginUserOperation(appId, localOneSignalId, badExternalId, "existing-osid"), + createSubscriptionOperation, + ), + ) + + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 0) { mockIdentityOperationExecutor.execute(any()) } + coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any()) } + } + test("a null byte login does not clear a different user's external id") { val badExternalId = "\u0000: 1" val mockUserBackendService = mockk() @@ -1006,4 +1053,547 @@ class LoginUserOperationExecutorTests : FunSpec({ // unstubbed-call failure; coVerify(exactly = 0) makes the expectation explicit). coVerify(exactly = 0) { mockIdentityOperationExecutor.execute(any()) } } + + fun profileExecutor( + backend: IUserBackendService, + subscriptions: SubscriptionModelStore = SubscriptionModelStore(MockPreferencesService()), + identityStore: IdentityModelStore = MockHelper.identityModelStore { it.onesignalId = localOneSignalId }, + propertiesStore: PropertiesModelStore = MockHelper.propertiesModelStore { it.onesignalId = localOneSignalId }, + identityExecutor: IdentityOperationExecutor = mockk(relaxed = true), + ivActive: Boolean = false, + ) = LoginUserOperationExecutor( + identityExecutor, + AndroidMockHelper.applicationService(), + MockHelper.deviceService(), + backend, + identityStore, + propertiesStore, + subscriptions, + MockHelper.configModelStore(), + MockHelper.languageContext(), + getJwtTokenStore(), + getIdentityVerificationService(newCodePathsRun = ivActive, ivBehaviorActive = ivActive), + mockk(relaxed = true), + ) + + test("matchingBackendSubscription matches by id even when another sub has the token") { + val remaining = + setOf( + SubscriptionObject(id = "by-id", type = SubscriptionObjectType.EMAIL, token = "other@b.com"), + SubscriptionObject(id = "by-token", type = SubscriptionObjectType.EMAIL, token = "a@b.com"), + ) + val local = SubscriptionObject(type = SubscriptionObjectType.EMAIL, token = "a@b.com") + + matchingBackendSubscription(remaining, "by-id", local)?.id shouldBe "by-id" + } + + test("matchingBackendSubscription matches by token when ids differ") { + val remaining = + setOf( + SubscriptionObject(id = "email-sub", type = SubscriptionObjectType.EMAIL, token = "a@b.com"), + SubscriptionObject(id = "push-sub", type = SubscriptionObjectType.ANDROID_PUSH, token = "push-token"), + ) + val local = SubscriptionObject(type = SubscriptionObjectType.EMAIL, token = "a@b.com") + + matchingBackendSubscription(remaining, "local-email", local)?.id shouldBe "email-sub" + } + + test("matchingBackendSubscription matches email tokens case-insensitively") { + val remaining = + setOf( + SubscriptionObject(id = "other-email", type = SubscriptionObjectType.EMAIL, token = "other@b.com"), + SubscriptionObject(id = "email-sub", type = SubscriptionObjectType.EMAIL, token = "A@B.COM"), + ) + val local = SubscriptionObject(type = SubscriptionObjectType.EMAIL, token = "a@b.com") + + matchingBackendSubscription(remaining, "local-email", local)?.id shouldBe "email-sub" + } + + test("matchingBackendSubscription matches a hashed email or SMS token") { + val remaining = + setOf( + SubscriptionObject(id = "email-sub", type = SubscriptionObjectType.EMAIL, token = PIIHasher.hash("a@b.com")), + SubscriptionObject(id = "sms-sub", type = SubscriptionObjectType.SMS, token = PIIHasher.hash("+15555550100")), + ) + val email = SubscriptionObject(type = SubscriptionObjectType.EMAIL, token = "a@b.com") + val sms = SubscriptionObject(type = SubscriptionObjectType.SMS, token = "+15555550100") + + matchingBackendSubscription(remaining, "local-email", email)?.id shouldBe "email-sub" + matchingBackendSubscription(remaining, "local-sms", sms)?.id shouldBe "sms-sub" + } + + test("matchingBackendSubscription maps push by type when id and token do not match") { + val remaining = + setOf(SubscriptionObject(id = "push-sub", type = SubscriptionObjectType.ANDROID_PUSH, token = null)) + val local = SubscriptionObject(type = SubscriptionObjectType.ANDROID_PUSH, token = "push-token") + + matchingBackendSubscription(remaining, "local-push", local)?.id shouldBe "push-sub" + } + + test("matchingBackendSubscription maps email or SMS by type only when one remains and it has no token") { + val emailRemaining = + setOf(SubscriptionObject(id = "email-sub", type = SubscriptionObjectType.EMAIL, token = null)) + val smsRemaining = + setOf(SubscriptionObject(id = "sms-sub", type = SubscriptionObjectType.SMS, token = null)) + val email = SubscriptionObject(type = SubscriptionObjectType.EMAIL, token = "a@b.com") + val sms = SubscriptionObject(type = SubscriptionObjectType.SMS, token = "+15555550100") + + matchingBackendSubscription(emailRemaining, "local-email", email)?.id shouldBe "email-sub" + matchingBackendSubscription(smsRemaining, "local-sms", sms)?.id shouldBe "sms-sub" + } + + test("matchingBackendSubscription does not take another email or SMS whose token did not match") { + val remaining = + setOf( + SubscriptionObject(id = "other-email", type = SubscriptionObjectType.EMAIL, token = "other@b.com"), + SubscriptionObject(id = "other-sms", type = SubscriptionObjectType.SMS, token = "+15555550999"), + ) + val email = SubscriptionObject(type = SubscriptionObjectType.EMAIL, token = "a@b.com") + val sms = SubscriptionObject(type = SubscriptionObjectType.SMS, token = "+15555550100") + + matchingBackendSubscription(remaining, "local-email", email) shouldBe null + matchingBackendSubscription(remaining, "local-sms", sms) shouldBe null + } + + test("matchingBackendSubscription does not type-fallback when two emails remain") { + val remaining = + setOf( + SubscriptionObject(id = "blank-email", type = SubscriptionObjectType.EMAIL, token = null), + SubscriptionObject(id = "other-email", type = SubscriptionObjectType.EMAIL, token = "other@b.com"), + ) + val local = SubscriptionObject(type = SubscriptionObjectType.EMAIL, token = "a@b.com") + + matchingBackendSubscription(remaining, "local-email", local) shouldBe null + } + + test("matchingBackendSubscription returns null when nothing matches") { + val remaining = + setOf(SubscriptionObject(id = "push-sub", type = SubscriptionObjectType.ANDROID_PUSH, token = "push-token")) + val local = SubscriptionObject(type = SubscriptionObjectType.EMAIL, token = "a@b.com") + + matchingBackendSubscription(remaining, "local-email", local) shouldBe null + } + + test("composite login succeeds when the backend omits an unchanged email subscription") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) + + val response = + profileExecutor(mockUserBackendService).execute( + listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(email = "a@b.com"))), + ) + + response.result shouldBe ExecutionResult.SUCCESS + response.metadata shouldBe LoginWaitMetadata(remoteOneSignalId) + } + + test("composite login with email only sends an Email subscription") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), + PropertiesObject(), + listOf(SubscriptionObject(id = "email-sub", type = SubscriptionObjectType.EMAIL, token = "a@b.com")), + ) + + val response = + profileExecutor(mockUserBackendService).execute( + listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(email = "a@b.com"))), + ) + + response.result shouldBe ExecutionResult.SUCCESS + response.metadata shouldBe LoginWaitMetadata(remoteOneSignalId, "email-sub") + coVerify(exactly = 1) { + mockUserBackendService.createUser( + appId, + mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), + withArg { subs -> + subs.single { it.type == SubscriptionObjectType.EMAIL }.token shouldBe "a@b.com" + subs.none { it.type == SubscriptionObjectType.SMS } shouldBe true + }, + any(), + ) + } + } + + test("composite login with phone only sends an SMS subscription") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), + PropertiesObject(), + listOf(SubscriptionObject(id = "sms-sub", type = SubscriptionObjectType.SMS, token = "+15555550100")), + ) + + val response = + profileExecutor(mockUserBackendService).execute( + listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(sms = "+15555550100"))), + ) + + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockUserBackendService.createUser( + appId, + mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), + withArg { subs -> + subs.single { it.type == SubscriptionObjectType.SMS }.token shouldBe "+15555550100" + subs.none { it.type == SubscriptionObjectType.EMAIL } shouldBe true + }, + any(), + ) + } + } + + test("composite login with tags only forwards them on createUser") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) + + val response = + profileExecutor(mockUserBackendService).execute( + listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(tags = mapOf("plan" to "pro")))), + ) + + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockUserBackendService.createUser( + appId, + mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), + any(), + withArg { props -> + props.tags shouldBe mapOf("plan" to "pro") + }, + ) + } + } + + test("composite login with aliases includes them on identity and skips reserved labels") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) + + val response = + profileExecutor(mockUserBackendService).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile( + aliases = + mapOf( + "facebook" to "bob", + IdentityConstants.ONESIGNAL_ID to "should-skip", + IdentityConstants.EXTERNAL_ID to "should-skip", + ), + ), + ), + ), + ) + + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockUserBackendService.createUser( + appId, + mapOf( + IdentityConstants.EXTERNAL_ID to "externalId", + "facebook" to "bob", + ), + any(), + any(), + ) + } + } + + test("composite login alias conflict fails without retry") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } throws BackendException(409, "CONFLICT") + + val response = + profileExecutor(mockUserBackendService).execute( + listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(aliases = mapOf("facebook" to "bob")))), + ) + + response.result shouldBe ExecutionResult.FAIL_NORETRY + response.httpStatusCode shouldBe 409 + response.httpResponse shouldBe "CONFLICT" + } + + test("composite login malformed email fails without retry") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } throws BackendException(400, "INVALID EMAIL") + + val response = + profileExecutor(mockUserBackendService).execute( + listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(email = "not-an-email"))), + ) + + response.result shouldBe ExecutionResult.FAIL_NORETRY + response.httpStatusCode shouldBe 400 + response.httpResponse shouldBe "INVALID EMAIL" + } + + test("composite login 404 fails without retry") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } throws BackendException(404, "NOT FOUND") + + val response = + profileExecutor(mockUserBackendService).execute( + listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(email = "a@b.com"))), + ) + + response.result shouldBe ExecutionResult.FAIL_NORETRY + response.httpStatusCode shouldBe 404 + } + + test("IV composite login missing the claimed email is unauthorized") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } throws BackendException(401, "UNAUTHORIZED") + + val response = + profileExecutor(mockUserBackendService, ivActive = true).execute( + listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(email = "a@b.com"))), + ) + + response.result shouldBe ExecutionResult.FAIL_UNAUTHORIZED + response.httpStatusCode shouldBe 401 + response.httpResponse shouldBe "UNAUTHORIZED" + } + + test("composite login does not take another email when the profile address is absent") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), + PropertiesObject(), + listOf(SubscriptionObject(id = "other-email", type = SubscriptionObjectType.EMAIL, token = "other@b.com")), + ) + + val response = + profileExecutor(mockUserBackendService).execute( + listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(email = "a@b.com"))), + ) + + response.result shouldBe ExecutionResult.SUCCESS + (response.metadata as LoginWaitMetadata).emailSubscriptionId shouldBe null + } + + test("composite login matches an email subscription case-insensitively") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), + PropertiesObject(), + listOf( + SubscriptionObject(id = "other-email", type = SubscriptionObjectType.EMAIL, token = "other@b.com"), + SubscriptionObject(id = "email-sub", type = SubscriptionObjectType.EMAIL, token = "A@B.COM"), + ), + ) + + val response = + profileExecutor(mockUserBackendService).execute( + listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(email = "a@b.com"))), + ) + + response.result shouldBe ExecutionResult.SUCCESS + response.metadata shouldBe LoginWaitMetadata(remoteOneSignalId, "email-sub") + } + + test("composite login skips hydration when current identity is a different user") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), + PropertiesObject(), + listOf(SubscriptionObject(id = "email-sub", type = SubscriptionObjectType.EMAIL, token = "a@b.com")), + ) + val identityStore = MockHelper.identityModelStore { it.onesignalId = "other-user" } + val propertiesStore = MockHelper.propertiesModelStore { it.onesignalId = "other-user" } + + val response = + profileExecutor(mockUserBackendService, identityStore = identityStore, propertiesStore = propertiesStore).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile( + email = "a@b.com", + tags = mapOf("plan" to "pro"), + aliases = mapOf("facebook" to "bob"), + ), + ), + ), + ) + + response.result shouldBe ExecutionResult.SUCCESS + identityStore.model.onesignalId shouldBe "other-user" + identityStore.model["facebook"] shouldBe null + propertiesStore.model.tags["plan"] shouldBe null + } + + test("composite login hydrates aliases tags and email subscription on success") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), + PropertiesObject(), + listOf(SubscriptionObject(id = "email-sub", type = SubscriptionObjectType.EMAIL, token = "a@b.com")), + ) + val identityStore = MockHelper.identityModelStore { it.onesignalId = localOneSignalId } + val propertiesStore = MockHelper.propertiesModelStore { it.onesignalId = localOneSignalId } + val subscriptions = SubscriptionModelStore(MockPreferencesService()) + + val response = + profileExecutor(mockUserBackendService, subscriptions, identityStore, propertiesStore).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile( + email = "a@b.com", + tags = mapOf("plan" to "pro"), + aliases = mapOf("facebook" to "bob"), + ), + ), + ), + ) + + response.result shouldBe ExecutionResult.SUCCESS + identityStore.model.onesignalId shouldBe remoteOneSignalId + identityStore.model["facebook"] shouldBe "bob" + propertiesStore.model.tags["plan"] shouldBe "pro" + subscriptions.list().single { it.type == SubscriptionType.EMAIL }.let { + it.id shouldBe "email-sub" + it.address shouldBe "a@b.com" + } + } + + test("composite login hydrates the backend tag set, not just the tags it sent") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse( + mapOf( + IdentityConstants.ONESIGNAL_ID to remoteOneSignalId, + "crm_id" to "CRM-1", + ), + PropertiesObject( + tags = mapOf("plan" to "enterprise", "seats" to "40", "dropped" to null), + language = "fr", + country = "FR", + ), + listOf(), + ) + val identityStore = MockHelper.identityModelStore { it.onesignalId = localOneSignalId } + val propertiesStore = MockHelper.propertiesModelStore { it.onesignalId = localOneSignalId } + + val response = + profileExecutor(mockUserBackendService, identityStore = identityStore, propertiesStore = propertiesStore).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile(tags = mapOf("plan" to "pro"), aliases = mapOf("facebook" to "bob")), + ), + ), + ) + + response.result shouldBe ExecutionResult.SUCCESS + propertiesStore.model.tags["plan"] shouldBe "enterprise" + propertiesStore.model.tags["seats"] shouldBe "40" + propertiesStore.model.tags["dropped"] shouldBe null + propertiesStore.model.language shouldBe "fr" + propertiesStore.model.country shouldBe "FR" + identityStore.model["crm_id"] shouldBe "CRM-1" + } + + test("composite login lets the backend value win over a stale local tag") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), + PropertiesObject(tags = mapOf("tag1" to "value1")), + listOf(), + ) + val propertiesStore = + MockHelper.propertiesModelStore { + it.onesignalId = localOneSignalId + it.tags["tag1"] = "value2" + } + + val response = + profileExecutor(mockUserBackendService, propertiesStore = propertiesStore).execute( + listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(tags = mapOf("tag1" to "value3")))), + ) + + response.result shouldBe ExecutionResult.SUCCESS + propertiesStore.model.tags["tag1"] shouldBe "value1" + } + + test("composite login with existingOnesignalId still createUsers so the profile is sent") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), + PropertiesObject(), + listOf(SubscriptionObject(id = "email-sub", type = SubscriptionObjectType.EMAIL, token = "a@b.com")), + ) + val identityExecutor = mockk() + + val response = + profileExecutor(mockUserBackendService, identityExecutor = identityExecutor).execute( + listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existing-osid", OneSignalUserProfile(email = "a@b.com"))), + ) + + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { mockUserBackendService.createUser(any(), any(), any(), any()) } + coVerify(exactly = 0) { identityExecutor.execute(any()) } + } + + test("identity-only login still associates via SetAlias when existingOnesignalId is set") { + val mockUserBackendService = mockk() + val identityExecutor = mockk() + coEvery { identityExecutor.execute(any()) } returns ExecutionResponse(ExecutionResult.SUCCESS) + + val response = + profileExecutor(mockUserBackendService, identityExecutor = identityExecutor).execute( + listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existing-osid")), + ) + + response.result shouldBe ExecutionResult.SUCCESS_STARTING_ONLY + coVerify(exactly = 0) { mockUserBackendService.createUser(any(), any(), any(), any()) } + coVerify(exactly = 1) { identityExecutor.execute(any()) } + } + + test("LoginUserOperation profile fields survive JSON round trip") { + val original = + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + "existing-osid", + OneSignalUserProfile( + email = "a@b.com", + sms = "+15555550100", + tags = mapOf("plan" to "pro"), + aliases = mapOf("facebook" to "bob"), + ), + ) + + val restored = LoginUserOperation() + restored.initializeFromJson(original.toJSON()) + + restored.email shouldBe "a@b.com" + restored.sms shouldBe "+15555550100" + restored.tags shouldBe mapOf("plan" to "pro") + restored.aliases shouldBe mapOf("facebook" to "bob") + restored.existingOnesignalId shouldBe "existing-osid" + } }) diff --git a/examples/demo/app/src/main/java/com/onesignal/example/data/model/CompositeLoginExample.java b/examples/demo/app/src/main/java/com/onesignal/example/data/model/CompositeLoginExample.java new file mode 100644 index 0000000000..56d719b9da --- /dev/null +++ b/examples/demo/app/src/main/java/com/onesignal/example/data/model/CompositeLoginExample.java @@ -0,0 +1,27 @@ +package com.onesignal.example.data.model; + +import com.onesignal.OneSignalUserProfile; +import java.util.Collections; +import java.util.Map; + +/** Java example of building a composite-login profile with OneSignalUserProfile.Builder. */ +public final class CompositeLoginExample { + private CompositeLoginExample() {} + + public static OneSignalUserProfile profile(String email, String sms) { + return profile(email, sms, Collections.emptyMap(), Collections.emptyMap()); + } + + public static OneSignalUserProfile profile( + String email, + String sms, + Map tags, + Map aliases) { + return new OneSignalUserProfile.Builder() + .setEmail(email) + .setSms(sms) + .setTags(tags) + .setAliases(aliases) + .build(); + } +} diff --git a/examples/demo/app/src/main/java/com/onesignal/example/data/repository/OneSignalRepository.kt b/examples/demo/app/src/main/java/com/onesignal/example/data/repository/OneSignalRepository.kt index 25863631cd..0f607c7d23 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/data/repository/OneSignalRepository.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/data/repository/OneSignalRepository.kt @@ -1,6 +1,7 @@ package com.onesignal.example.data.repository import com.onesignal.OneSignal +import com.onesignal.example.data.model.CompositeLoginExample import com.onesignal.example.data.model.NotificationType import com.onesignal.example.data.network.OneSignalService import com.onesignal.example.data.network.UserData @@ -21,6 +22,19 @@ class OneSignalRepository { DemoLog.d("Logged in user with onesignalId: ${OneSignal.User.onesignalId}") } + suspend fun loginUserWithProfile( + externalUserId: String, + email: String?, + sms: String?, + jwtToken: String? = null, + ) = withContext(Dispatchers.IO) { + DemoLog.d("Composite login externalUserId: $externalUserId email=$email sms=$sms") + val profile = CompositeLoginExample.profile(email, sms) + val result = OneSignal.login(externalUserId, profile, jwtToken) + DemoLog.d("Composite login result: $result") + result + } + suspend fun updateUserJwt(externalUserId: String, jwtToken: String) = withContext(Dispatchers.IO) { DemoLog.d("Updating JWT for externalUserId: $externalUserId") OneSignal.updateUserJwt(externalUserId, jwtToken) diff --git a/examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt b/examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt index 9e0bc8f968..3ae57714bb 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt @@ -465,6 +465,102 @@ fun LoginDialog( ) } +@Composable +fun CompositeLoginDialog( + onDismiss: () -> Unit, + onConfirm: (externalId: String, email: String?, sms: String?, jwt: String?) -> Unit, +) { + var externalId by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } + var sms by remember { mutableStateOf("") } + var jwtToken by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.fillMaxWidth().padding(horizontal = DemoLayout.pagePadding).exposeTestTagsAsResourceId(), + properties = DialogProperties(usePlatformDefaultWidth = false), + title = { + DialogTitle("Login With Profile") + }, + text = { + Column { + OutlinedTextField( + value = externalId, + onValueChange = { externalId = it }, + label = { Text("External User Id") }, + modifier = Modifier + .fillMaxWidth() + .testTag("composite_login_user_id_input"), + singleLine = true, + shape = TextFieldShape, + colors = dialogTextFieldColors() + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email (optional)") }, + modifier = Modifier + .fillMaxWidth() + .testTag("composite_login_email_input"), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + shape = TextFieldShape, + colors = dialogTextFieldColors() + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = sms, + onValueChange = { sms = it }, + label = { Text("SMS (optional)") }, + modifier = Modifier + .fillMaxWidth() + .testTag("composite_login_sms_input"), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + shape = TextFieldShape, + colors = dialogTextFieldColors() + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = jwtToken, + onValueChange = { jwtToken = it }, + label = { Text("JWT Token (optional)") }, + modifier = Modifier + .fillMaxWidth() + .testTag("composite_login_jwt_input"), + singleLine = true, + shape = TextFieldShape, + colors = dialogTextFieldColors() + ) + } + }, + confirmButton = { + TextButton( + onClick = { + onConfirm( + externalId, + email.ifBlank { null }, + sms.ifBlank { null }, + jwtToken.ifBlank { null }, + ) + }, + enabled = externalId.isNotBlank(), + modifier = Modifier.testTag("composite_login_confirm_button"), + colors = dialogTextButtonColors(), + ) { + DialogActionLabel("Login") + } + }, + dismissButton = { + TextButton(onClick = onDismiss, colors = dialogTextButtonColors()) { + DialogActionLabel("Cancel") + } + }, + shape = DialogShape + ) +} + /** * Dialog for outcome selection and input. */ diff --git a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainScreen.kt b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainScreen.kt index 633ae9b936..2ccbaa6640 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainScreen.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainScreen.kt @@ -145,6 +145,9 @@ fun MainScreen(viewModel: MainViewModel) { onUseIdentityVerificationChange = { viewModel.setUseIdentityVerification(it) }, onLanguageChange = { viewModel.setLanguage(it) }, onLogin = { userId, jwt -> viewModel.loginUser(userId, jwt) }, + onLoginWithProfile = { userId, email, sms, jwt -> + viewModel.loginUserWithProfile(userId, email, sms, jwt) + }, onLogout = { viewModel.logoutUser() }, onUpdateJwt = { externalId, token -> viewModel.updateUserJwt(externalId, token) }, isLoading = isLoading diff --git a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainViewModel.kt b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainViewModel.kt index ec565c9199..3c06b7e2a3 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainViewModel.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainViewModel.kt @@ -300,6 +300,38 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I } } + fun loginUserWithProfile( + externalUserId: String, + email: String?, + sms: String?, + jwtToken: String? = null, + ) { + _isLoading.value = true + viewModelScope.launch(Dispatchers.IO) { + val result = repository.loginUserWithProfile(externalUserId, email, sms, jwtToken) + withContext(Dispatchers.Main) { + if (result.isSuccess) { + SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), externalUserId) + SharedPreferenceUtil.cacheJwtToken(getApplication(), jwtToken) + _externalUserId.value = externalUserId + aliasesList.clear() + emailsList.clear() + smsNumbersList.clear() + triggersList.clear() + refreshAliases() + refreshEmails() + refreshSmsNumbers() + refreshTriggers() + loadExistingTags() + refreshPushSubscription() + } else { + DemoLog.e("Composite login failed: ${result.error}") + } + _isLoading.value = false + } + } + } + fun updateUserJwt(externalUserId: String, jwtToken: String) { viewModelScope.launch(Dispatchers.IO) { repository.updateUserJwt(externalUserId, jwtToken) diff --git a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/Sections.kt b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/Sections.kt index d523d2a5d1..d428f9338c 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/Sections.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/Sections.kt @@ -31,6 +31,7 @@ import com.onesignal.example.ui.components.DemoSection import com.onesignal.example.ui.components.DestructiveButton import com.onesignal.example.ui.components.DropdownRow import com.onesignal.example.ui.components.LocalSnackbarController +import com.onesignal.example.ui.components.CompositeLoginDialog import com.onesignal.example.ui.components.LoginDialog import com.onesignal.example.ui.components.MultiPairInputDialog import com.onesignal.example.ui.components.MultiSelectRemoveDialog @@ -184,12 +185,14 @@ fun UserSection( onUseIdentityVerificationChange: (Boolean) -> Unit, onLanguageChange: (String) -> Unit, onLogin: (String, String?) -> Unit, + onLoginWithProfile: (String, String?, String?, String?) -> Unit, onLogout: () -> Unit, onUpdateJwt: (String, String) -> Unit, isLoading: Boolean = false, ) { val isLoggedIn = !externalUserId.isNullOrEmpty() var loginOpen by remember { mutableStateOf(false) } + var compositeLoginOpen by remember { mutableStateOf(false) } var updateJwtOpen by remember { mutableStateOf(false) } DemoSection { @@ -235,6 +238,13 @@ fun UserSection( testTag = "login_user_button", ) + OutlineButton( + text = "LOGIN WITH PROFILE", + onClick = { compositeLoginOpen = true }, + enabled = !isLoading, + testTag = "composite_login_button", + ) + if (isLoggedIn) { OutlineButton( text = "LOGOUT USER", @@ -261,6 +271,16 @@ fun UserSection( ) } + if (compositeLoginOpen) { + CompositeLoginDialog( + onDismiss = { compositeLoginOpen = false }, + onConfirm = { userId, email, sms, jwt -> + onLoginWithProfile(userId, email, sms, jwt) + compositeLoginOpen = false + }, + ) + } + if (updateJwtOpen) { PairInputDialog( title = "Update User JWT", From 26decb3d78025598f3698efd7bcbe8d3a5aed592 Mon Sep 17 00:00:00 2001 From: abdulraqeeb33 Date: Mon, 12 Oct 2026 14:39:04 -0500 Subject: [PATCH 04/20] fix: [SDK-4952] stop the settings fallback from stacking on Huawei's notification prompt (#2779) Co-authored-by: AR Abdul Azeez Co-authored-by: Cursor --- .../preferences/IPreferencesService.kt | 6 + .../notifications/INotificationsManager.kt | 2 +- .../impl/NotificationPermissionController.kt | 44 ++++ ...ficationPermissionControllerLegacyTests.kt | 218 ++++++++++++++++++ .../NotificationPermissionControllerTests.kt | 59 ++++- 5 files changed, 320 insertions(+), 9 deletions(-) create mode 100644 OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/permission/NotificationPermissionControllerLegacyTests.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/IPreferencesService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/IPreferencesService.kt index 78796431ef..f49cf7705b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/IPreferencesService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/IPreferencesService.kt @@ -223,6 +223,12 @@ object PreferenceOneSignalKeys { */ const val PREFS_OS_PROMPTED_PERMISSION_PREFIX = "PROMPTED_PERMISSION_" + /** + * (Boolean) Set when the first SDK session starts. On a fresh install without the native + * notification prompt, that session defers the settings fallback (disabled may be an OEM default). + */ + const val PREFS_OS_NOTIFICATION_SETTINGS_FALLBACK_ALLOWED = "NOTIFICATION_SETTINGS_FALLBACK_ALLOWED" + // HTTP /** diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/notifications/INotificationsManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/notifications/INotificationsManager.kt index 2a558a5973..6490752c3b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/notifications/INotificationsManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/notifications/INotificationsManager.kt @@ -26,7 +26,7 @@ interface INotificationsManager { * enabling of notifications, when the in-app prompting is not possible. * * @return true if the user is opted in to push notifications (Android 13 and higher, user affirmed or already enabled. < Android 13, already enabled) - * false if the user is opted out of push notifications (user rejected) + * false if notifications are not enabled (user rejected, or on devices without the runtime prompt, no prompt shown yet; observe permission changes) */ suspend fun requestPermission(fallbackToSettings: Boolean): Boolean diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/permissions/impl/NotificationPermissionController.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/permissions/impl/NotificationPermissionController.kt index 0c42d634be..184dc6655d 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/permissions/impl/NotificationPermissionController.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/permissions/impl/NotificationPermissionController.kt @@ -47,6 +47,7 @@ import com.onesignal.core.internal.permissions.IRequestPermissionService import com.onesignal.core.internal.preferences.IPreferencesService import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.debug.internal.logging.Logging import com.onesignal.notifications.R import com.onesignal.notifications.internal.common.NotificationHelper import com.onesignal.notifications.internal.permissions.INotificationPermissionChangedHandler @@ -69,6 +70,35 @@ internal class NotificationPermissionController( private val events = EventProducer() private var enabled: Boolean + @Volatile + private var notificationsSeenEnabled = false + + // Without a native prompt, disabled may be an OEM default (e.g. Huawei) whose own prompt is on screen, so a + // fresh install's first session never shows our dialog while notifications were never seen enabled. + // An app update means the app launched before; unknown install times count as fresh. + private val defersSettingsFallbackThisSession: Boolean by lazy { + val firstSession = + _preferenceService.getBool( + PreferenceStores.ONESIGNAL, + PreferenceOneSignalKeys.PREFS_OS_NOTIFICATION_SETTINGS_FALLBACK_ALLOWED, + false, + ) != true + if (firstSession) { + _preferenceService.saveBool( + PreferenceStores.ONESIGNAL, + PreferenceOneSignalKeys.PREFS_OS_NOTIFICATION_SETTINGS_FALLBACK_ALLOWED, + true, + ) + } + val context = _application.appContext + val freshInstall = + runCatching { + val info = context.packageManager.getPackageInfo(context.packageName, 0) + info.firstInstallTime == info.lastUpdateTime + }.getOrDefault(true) + firstSession && freshInstall + } + override val canRequestPermission: Boolean get() = !_preferenceService.getBool( @@ -79,6 +109,7 @@ internal class NotificationPermissionController( init { this.enabled = notificationsEnabled() + notificationsSeenEnabled = this.enabled _requestPermission.registerAsCallback(PERMISSION_TYPE, this) pollingWaitInterval = _configModelStore.model.backgroundFetchNotificationPermissionInterval registerPollingLifecycleListener() @@ -113,8 +144,15 @@ internal class NotificationPermissionController( } private suspend fun pollForPermission() { + // Decided at startup, not at the first prompt, so a later session's prompt is never deferred. + if (defersSettingsFallbackThisSession) { + Logging.debug("NotificationPermissionController: first session, settings fallback may be deferred if notifications stay disabled") + } while (true) { val enabled = this.notificationsEnabled() + if (enabled) { + notificationsSeenEnabled = true + } if (this.enabled != enabled) { // If the permission has changed without prompting through OneSignal this.enabled = enabled events.fire { it.onNotificationPermissionChanged(enabled) } @@ -145,6 +183,7 @@ internal class NotificationPermissionController( yield() if (notificationsEnabled()) { + notificationsSeenEnabled = true return true } @@ -152,6 +191,11 @@ internal class NotificationPermissionController( return false } + if (!supportsNativePrompt && defersSettingsFallbackThisSession && !notificationsSeenEnabled) { + Logging.info("NotificationPermissionController: notifications never seen enabled this session, settings fallback deferred") + return false + } + return inFlightPrompt.share(false) { if (supportsNativePrompt) { _requestPermission.startPrompt( diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/permission/NotificationPermissionControllerLegacyTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/permission/NotificationPermissionControllerLegacyTests.kt new file mode 100644 index 0000000000..f88df02408 --- /dev/null +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/permission/NotificationPermissionControllerLegacyTests.kt @@ -0,0 +1,218 @@ +package com.onesignal.notifications.internal.permission + +import android.app.Activity +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.core.internal.application.IApplicationLifecycleHandler +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.permissions.AlertDialogPrepromptForAndroidSettings +import com.onesignal.core.internal.permissions.IRequestPermissionService +import com.onesignal.core.internal.preferences.IPreferencesService +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.mocks.MockHelper +import com.onesignal.notifications.internal.permissions.INotificationPermissionChangedHandler +import com.onesignal.notifications.internal.permissions.impl.NotificationPermissionController +import com.onesignal.notifications.shadows.ShadowRoboNotificationManager +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.CapturingSlot +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.runs +import io.mockk.slot +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.concurrent.ConcurrentHashMap + +private const val FALLBACK_ALLOWED_KEY = PreferenceOneSignalKeys.PREFS_OS_NOTIFICATION_SETTINGS_FALLBACK_ALLOWED + +private suspend fun awaitCondition(condition: () -> Boolean) = + withTimeout(2_000) { + while (!condition()) delay(10) + } + +@Config( + packageName = "com.onesignal.example", + shadows = [ShadowRoboNotificationManager::class], + sdk = [31], +) +@RobolectricTest +class NotificationPermissionControllerLegacyTests : FunSpec({ + lateinit var prefs: ConcurrentHashMap + lateinit var mockPreferenceService: IPreferencesService + lateinit var mockAppService: IApplicationService + lateinit var lifecycleHandlers: MutableList + lateinit var hostActivity: Activity + lateinit var dialogCallback: CapturingSlot + + fun installApp( + firstInstallTime: Long, + lastUpdateTime: Long, + ) { + val context = ApplicationProvider.getApplicationContext() + shadowOf(context.packageManager).installPackage( + PackageInfo().also { + it.packageName = context.packageName + it.firstInstallTime = firstInstallTime + it.lastUpdateTime = lastUpdateTime + it.applicationInfo = ApplicationInfo().also { app -> app.packageName = context.packageName } + }, + ) + } + + suspend fun startSession(focused: Boolean = true): NotificationPermissionController { + val controller = + NotificationPermissionController( + mockAppService, + mockk().also { + every { it.registerAsCallback(any(), any()) } just runs + }, + mockAppService, + mockPreferenceService, + MockHelper.configModelStore(), + ) + if (focused) lifecycleHandlers.forEach { it.onFocus(false) } + awaitCondition { prefs[FALLBACK_ALLOWED_KEY] == true } + return controller + } + + suspend fun promptAndDecline(controller: NotificationPermissionController): Boolean = + coroutineScope { + val pending = async { controller.prompt(true) } + awaitCondition { dialogCallback.isCaptured } + dialogCallback.captured.onDecline() + pending.await() + } + + beforeEach { + Logging.logLevel = LogLevel.NONE + ShadowRoboNotificationManager.reset() + installApp(firstInstallTime = 1_000, lastUpdateTime = 1_000) + + prefs = ConcurrentHashMap() + mockPreferenceService = mockk(relaxed = true) + every { mockPreferenceService.getBool(any(), any(), any()) } answers { + prefs[secondArg()] ?: thirdArg() + } + every { mockPreferenceService.saveBool(any(), any(), any()) } answers { + prefs[secondArg()] = thirdArg() ?: false + } + + hostActivity = mockk(relaxed = true) + every { hostActivity.getString(any()) } returns "Notifications" + lifecycleHandlers = mutableListOf() + mockAppService = mockk(relaxed = true) + every { mockAppService.appContext } returns ApplicationProvider.getApplicationContext() + every { mockAppService.current } returns hostActivity + every { mockAppService.addApplicationLifecycleHandler(any()) } answers { + lifecycleHandlers.add(firstArg()) + } + + dialogCallback = slot() + mockkObject(AlertDialogPrepromptForAndroidSettings) + every { + AlertDialogPrepromptForAndroidSettings.show(any(), any(), any(), capture(dialogCallback)) + } just runs + } + + afterEach { + unmockkObject(AlertDialogPrepromptForAndroidSettings) + } + + test("first session with notifications never enabled defers every settings fallback request") { + ShadowRoboNotificationManager.notificationsEnabled = false + val controller = startSession() + + withTimeout(1_000) { controller.prompt(true) } shouldBe false + withTimeout(1_000) { controller.prompt(true) } shouldBe false + + verify(exactly = 0) { AlertDialogPrepromptForAndroidSettings.show(any(), any(), any(), any()) } + } + + test("simultaneous first session requests do not show the settings dialog") { + ShadowRoboNotificationManager.notificationsEnabled = false + val controller = startSession() + + val results = + withTimeout(1_000) { + coroutineScope { + listOf(async { controller.prompt(true) }, async { controller.prompt(true) }).awaitAll() + } + } + + results shouldBe listOf(false, false) + verify(exactly = 0) { AlertDialogPrepromptForAndroidSettings.show(any(), any(), any(), any()) } + } + + test("session is decided at startup, so a later session shows the dialog on its first request") { + ShadowRoboNotificationManager.notificationsEnabled = false + startSession() + + promptAndDecline(startSession()) shouldBe false + + verify(exactly = 1) { AlertDialogPrepromptForAndroidSettings.show(hostActivity, any(), any(), any()) } + } + + test("an updated app shows the dialog on its first request, since it launched before the update") { + installApp(firstInstallTime = 1_000, lastUpdateTime = 2_000) + ShadowRoboNotificationManager.notificationsEnabled = false + + promptAndDecline(startSession()) shouldBe false + + verify(exactly = 1) { AlertDialogPrepromptForAndroidSettings.show(hostActivity, any(), any(), any()) } + } + + test("a prompt that finds notifications enabled lifts the deferral before polling sees it") { + ShadowRoboNotificationManager.notificationsEnabled = false + val controller = startSession(focused = false) + + ShadowRoboNotificationManager.notificationsEnabled = true + withTimeout(1_000) { controller.prompt(true) } shouldBe true + ShadowRoboNotificationManager.notificationsEnabled = false + + promptAndDecline(controller) shouldBe false + verify(exactly = 1) { AlertDialogPrepromptForAndroidSettings.show(hostActivity, any(), any(), any()) } + } + + test("notifications seen enabled this session show the dialog once they are turned off") { + val controller = startSession() + + ShadowRoboNotificationManager.notificationsEnabled = false + promptAndDecline(controller) shouldBe false + + verify(exactly = 1) { AlertDialogPrepromptForAndroidSettings.show(hostActivity, any(), any(), any()) } + } + + test("an Allow on the OEM prompt after a deferral fires the permission changed event") { + ShadowRoboNotificationManager.notificationsEnabled = false + val controller = startSession() + var changedTo: Boolean? = null + controller.subscribe( + object : INotificationPermissionChangedHandler { + override fun onNotificationPermissionChanged(enabled: Boolean) { + changedTo = enabled + } + }, + ) + withTimeout(1_000) { controller.prompt(true) } shouldBe false + + ShadowRoboNotificationManager.notificationsEnabled = true + lifecycleHandlers.forEach { it.onFocus(false) } + + awaitCondition { changedTo == true } + } +}) diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/permission/NotificationPermissionControllerTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/permission/NotificationPermissionControllerTests.kt index 2fc646ce51..282139a1f1 100644 --- a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/permission/NotificationPermissionControllerTests.kt +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/permission/NotificationPermissionControllerTests.kt @@ -3,6 +3,7 @@ package com.onesignal.notifications.internal.permission import android.app.Activity import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.common.AndroidUtils import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.common.threading.runOnSerialIO import com.onesignal.core.activities.PermissionsActivity @@ -31,7 +32,10 @@ import io.mockk.unmockkObject import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout import org.robolectric.annotation.Config @Config( @@ -54,7 +58,7 @@ class NotificationPermissionControllerTests : FunSpec({ // Given val mockRequestPermissionService = mockk() every { mockRequestPermissionService.registerAsCallback(any(), any()) } just runs - val mockPreferenceService = mockk() + val mockPreferenceService = mockk(relaxed = true) val focusHandlerList = mutableListOf() val mockAppService = mockk() every { mockAppService.addApplicationLifecycleHandler(any()) } answers { @@ -91,7 +95,7 @@ class NotificationPermissionControllerTests : FunSpec({ // Given val mockRequestPermissionService = mockk() every { mockRequestPermissionService.registerAsCallback(any(), any()) } just runs - val mockPreferenceService = mockk() + val mockPreferenceService = mockk(relaxed = true) val handlerList = mutableListOf() val mockAppService = mockk() every { mockAppService.addApplicationLifecycleHandler(any()) } answers { @@ -130,13 +134,52 @@ class NotificationPermissionControllerTests : FunSpec({ handlerFired shouldBe false } + test("first session with notifications disabled still launches the native prompt") { + mockkObject(AndroidUtils) + every { AndroidUtils.getTargetSdkVersion(any()) } returns 33 + + try { + ShadowRoboNotificationManager.setShadowNotificationsEnabled(false) + var nativePromptStarted = false + val mockRequestPermissionService = mockk() + every { mockRequestPermissionService.registerAsCallback(any(), any()) } just runs + every { mockRequestPermissionService.startPrompt(any(), any(), any(), any()) } answers { nativePromptStarted = true } + val mockPreferenceService = mockk(relaxed = true) + every { mockPreferenceService.getBool(any(), any(), any()) } returns false + val mockAppService = mockk(relaxed = true) + every { mockAppService.appContext } returns ApplicationProvider.getApplicationContext() + val controller = + NotificationPermissionController( + mockAppService, + mockRequestPermissionService, + mockAppService, + mockPreferenceService, + MockHelper.configModelStore(), + ) + + val result = + withTimeout(1_000) { + coroutineScope { + val pending = async { controller.prompt(true) } + while (!nativePromptStarted) delay(10) + controller.onAccept() + pending.await() + } + } + + result shouldBe true + } finally { + unmockkObject(AndroidUtils) + } + } + test("onReject with fallback waits for host activity before showing settings dialog") { mockkObject(AlertDialogPrepromptForAndroidSettings) try { val mockRequestPermissionService = mockk() every { mockRequestPermissionService.registerAsCallback(any(), any()) } just runs - val mockPreferenceService = mockk() + val mockPreferenceService = mockk(relaxed = true) val activityHandlers = mutableListOf() val mockAppService = mockk() val permissionsActivity = mockk(relaxed = true) @@ -209,7 +252,7 @@ class NotificationPermissionControllerTests : FunSpec({ try { val mockRequestPermissionService = mockk() every { mockRequestPermissionService.registerAsCallback(any(), any()) } just runs - val mockPreferenceService = mockk() + val mockPreferenceService = mockk(relaxed = true) val activityHandlers = mutableListOf() val mockAppService = mockk() val hostActivity = mockk(relaxed = true) @@ -278,7 +321,7 @@ class NotificationPermissionControllerTests : FunSpec({ try { val mockRequestPermissionService = mockk() every { mockRequestPermissionService.registerAsCallback(any(), any()) } just runs - val mockPreferenceService = mockk() + val mockPreferenceService = mockk(relaxed = true) val mockAppService = mockk() val hostActivity = mockk(relaxed = true) val callbackSlot = slot() @@ -349,7 +392,7 @@ class NotificationPermissionControllerTests : FunSpec({ try { val mockRequestPermissionService = mockk() every { mockRequestPermissionService.registerAsCallback(any(), any()) } just runs - val mockPreferenceService = mockk() + val mockPreferenceService = mockk(relaxed = true) val focusHandlerList = mutableListOf() val mockAppService = mockk() every { mockAppService.addApplicationLifecycleHandler(any()) } answers { @@ -388,7 +431,7 @@ class NotificationPermissionControllerTests : FunSpec({ try { val mockRequestPermissionService = mockk() every { mockRequestPermissionService.registerAsCallback(any(), any()) } just runs - val mockPreferenceService = mockk() + val mockPreferenceService = mockk(relaxed = true) val focusHandlerList = mutableListOf() val mockAppService = mockk() every { mockAppService.addApplicationLifecycleHandler(any()) } answers { @@ -419,7 +462,7 @@ class NotificationPermissionControllerTests : FunSpec({ // Given val mockRequestPermissionService = mockk() every { mockRequestPermissionService.registerAsCallback(any(), any()) } just runs - val mockPreferenceService = mockk() + val mockPreferenceService = mockk(relaxed = true) val handlerList = mutableListOf() val mockAppService = mockk() every { mockAppService.addApplicationLifecycleHandler(any()) } answers { From 5528c3dcd2d40d541566be3bada0de46912602ae Mon Sep 17 00:00:00 2001 From: abdulraqeeb33 Date: Wed, 14 Oct 2026 13:58:39 -0500 Subject: [PATCH 05/20] chore: [SDK-5024] dump core.api for composite login (#2787) Co-authored-by: AR Abdul Azeez Co-authored-by: Cursor --- OneSignalSDK/onesignal/core/api/core.api | 109 ++++++++++++++++++ .../main/java/com/onesignal/OneSignalError.kt | 28 ++--- .../java/com/onesignal/OneSignalResult.kt | 11 +- .../java/com/onesignal/OneSignalResultData.kt | 7 +- .../com/onesignal/OneSignalUserProfile.kt | 44 ++++--- .../java/com/onesignal/OneSignalErrorTests.kt | 6 +- .../com/onesignal/OneSignalResultTests.kt | 12 +- .../onesignal/OneSignalUserProfileTests.kt | 8 ++ 8 files changed, 176 insertions(+), 49 deletions(-) diff --git a/OneSignalSDK/onesignal/core/api/core.api b/OneSignalSDK/onesignal/core/api/core.api index 432f1d6490..0a26d1da1c 100644 --- a/OneSignalSDK/onesignal/core/api/core.api +++ b/OneSignalSDK/onesignal/core/api/core.api @@ -13,6 +13,26 @@ public final class com/onesignal/ContinueResult { public final fun isSuccess ()Z } +public final class com/onesignal/ErrorCode : java/lang/Enum { + public static final field BACKEND_ERROR Lcom/onesignal/ErrorCode; + public static final field INVALID_ARGUMENT Lcom/onesignal/ErrorCode; + public static final field NOT_INITIALIZED Lcom/onesignal/ErrorCode; + public static final field STORAGE_LOCKED Lcom/onesignal/ErrorCode; + public static final field UNKNOWN Lcom/onesignal/ErrorCode; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getSource ()Lcom/onesignal/ErrorSource; + public static fun valueOf (Ljava/lang/String;)Lcom/onesignal/ErrorCode; + public static fun values ()[Lcom/onesignal/ErrorCode; +} + +public final class com/onesignal/ErrorSource : java/lang/Enum { + public static final field BACKEND Lcom/onesignal/ErrorSource; + public static final field CLIENT Lcom/onesignal/ErrorSource; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/onesignal/ErrorSource; + public static fun values ()[Lcom/onesignal/ErrorSource; +} + public abstract interface class com/onesignal/IOneSignal { public abstract fun addUserJwtInvalidatedListener (Lcom/onesignal/IUserJwtInvalidatedListener;)V public abstract fun getConsentGiven ()Z @@ -38,6 +58,7 @@ public abstract interface class com/onesignal/IOneSignal { public abstract fun initWithContextSuspend (Landroid/content/Context;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun isInitialized ()Z public abstract fun login (Ljava/lang/String;)V + public abstract fun login (Ljava/lang/String;Lcom/onesignal/OneSignalUserProfile;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun login (Ljava/lang/String;Ljava/lang/String;)V public abstract fun loginSuspend (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun logout ()V @@ -56,6 +77,7 @@ public abstract interface class com/onesignal/IOneSignal { public final class com/onesignal/IOneSignal$DefaultImpls { public static synthetic fun initWithContextSuspend$default (Lcom/onesignal/IOneSignal;Landroid/content/Context;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static fun login (Lcom/onesignal/IOneSignal;Ljava/lang/String;)V + public static synthetic fun login$default (Lcom/onesignal/IOneSignal;Ljava/lang/String;Lcom/onesignal/OneSignalUserProfile;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun login$default (Lcom/onesignal/IOneSignal;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V public static synthetic fun loginSuspend$default (Lcom/onesignal/IOneSignal;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } @@ -64,6 +86,25 @@ public abstract interface class com/onesignal/IUserJwtInvalidatedListener { public abstract fun onUserJwtInvalidated (Lcom/onesignal/UserJwtInvalidatedEvent;)V } +public final class com/onesignal/InitData : com/onesignal/OneSignalResultData { + public fun toMap ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + +public final class com/onesignal/LoginData : com/onesignal/OneSignalResultData { + public final fun getEmailSubscriptionId ()Ljava/lang/String; + public final fun getExternalId ()Ljava/lang/String; + public final fun getOnesignalId ()Ljava/lang/String; + public final fun getSmsSubscriptionId ()Ljava/lang/String; + public fun toMap ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + +public final class com/onesignal/LogoutData : com/onesignal/OneSignalResultData { + public fun toMap ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + public final class com/onesignal/OneSignal { public static final field INSTANCE Lcom/onesignal/OneSignal; public static final fun addUserJwtInvalidatedListener (Lcom/onesignal/IUserJwtInvalidatedListener;)V @@ -92,7 +133,10 @@ public final class com/onesignal/OneSignal { public static synthetic fun initWithContextSuspend$default (Landroid/content/Context;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static final fun isInitialized ()Z public static final fun login (Ljava/lang/String;)V + public static final fun login (Ljava/lang/String;Lcom/onesignal/OneSignalUserProfile;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun login (Ljava/lang/String;Lcom/onesignal/OneSignalUserProfile;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun login (Ljava/lang/String;Ljava/lang/String;)V + public static synthetic fun login$default (Ljava/lang/String;Lcom/onesignal/OneSignalUserProfile;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun login$default (Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V public static final fun loginSuspend (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun loginSuspend$default (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; @@ -109,12 +153,77 @@ public final class com/onesignal/OneSignal { public static final fun updateUserJwtSuspend (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class com/onesignal/OneSignalError { + public synthetic fun (Ljava/util/List;Ljava/lang/Throwable;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getCause ()Ljava/lang/Throwable; + public final fun getDetails ()Ljava/util/List; + public final fun getFirst ()Lcom/onesignal/OneSignalError$Detail; + public final fun toList ()Ljava/util/List; + public fun toString ()Ljava/lang/String; +} + +public final class com/onesignal/OneSignalError$Detail { + public synthetic fun (Lcom/onesignal/ErrorCode;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Lcom/onesignal/ErrorSource;Ljava/util/Map;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getBackendCode ()Ljava/lang/String; + public final fun getCode ()Lcom/onesignal/ErrorCode; + public final fun getHttpStatus ()Ljava/lang/Integer; + public final fun getMessage ()Ljava/lang/String; + public final fun getMeta ()Ljava/util/Map; + public final fun getSource ()Lcom/onesignal/ErrorSource; + public final fun toMap ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + +public final class com/onesignal/OneSignalResult { + public synthetic fun (Lcom/onesignal/OneSignalResultData;Lcom/onesignal/OneSignalError;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getData ()Lcom/onesignal/OneSignalResultData; + public final fun getError ()Lcom/onesignal/OneSignalError; + public final fun isSuccess ()Z + public final fun toMap ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/onesignal/OneSignalResultData { + public abstract fun toMap ()Ljava/util/Map; +} + +public final class com/onesignal/OneSignalUserProfile { + public static final field Companion Lcom/onesignal/OneSignalUserProfile$Companion; + public synthetic fun (Lcom/onesignal/OneSignalUserProfile$Builder;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun builder ()Lcom/onesignal/OneSignalUserProfile$Builder; + public fun equals (Ljava/lang/Object;)Z + public final fun getAliases ()Ljava/util/Map; + public final fun getEmail ()Ljava/lang/String; + public final fun getSms ()Ljava/lang/String; + public final fun getTags ()Ljava/util/Map; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/onesignal/OneSignalUserProfile$Builder { + public fun ()V + public final fun build ()Lcom/onesignal/OneSignalUserProfile; + public final fun setAliases (Ljava/util/Map;)Lcom/onesignal/OneSignalUserProfile$Builder; + public final fun setEmail (Ljava/lang/String;)Lcom/onesignal/OneSignalUserProfile$Builder; + public final fun setSms (Ljava/lang/String;)Lcom/onesignal/OneSignalUserProfile$Builder; + public final fun setTags (Ljava/util/Map;)Lcom/onesignal/OneSignalUserProfile$Builder; +} + +public final class com/onesignal/OneSignalUserProfile$Companion { + public final fun builder ()Lcom/onesignal/OneSignalUserProfile$Builder; +} + public final class com/onesignal/SyncJobService : android/app/job/JobService { public fun ()V public fun onStartJob (Landroid/app/job/JobParameters;)Z public fun onStopJob (Landroid/app/job/JobParameters;)Z } +public final class com/onesignal/UpdateUserJwtData : com/onesignal/OneSignalResultData { + public fun toMap ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + public final class com/onesignal/UserJwtInvalidatedEvent { public fun (Ljava/lang/String;)V public final fun getExternalId ()Ljava/lang/String; diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalError.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalError.kt index 334973f410..d25fc02abc 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalError.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalError.kt @@ -24,7 +24,10 @@ enum class ErrorSource { * * An enum rather than a sealed hierarchy so that Java callers get a native `switch` and the * wrapper bridges get a trivial name-to-string marshal. The backend half of the catalog is - * deliberately *not* modelled here — see [BACKEND_ERROR]. + * deliberately *not* modelled here; see [BACKEND_ERROR]. + * + * New codes will be added in later releases, so a `when` or `switch` over this enum should keep an + * `else` branch. */ enum class ErrorCode(val source: ErrorSource) { /** [IOneSignal.initWithContextSuspend] has not been called. */ @@ -49,7 +52,7 @@ enum class ErrorCode(val source: ErrorSource) { /** * Describes why a OneSignal call failed. * - * One request can fail for several reasons at once, so [error] is a list of [Detail]. Everything + * One request can fail for several reasons at once, so [details] is a list of [Detail]. Everything * the SDK raises locally has exactly one reason, which [first] reads without the indexing * ceremony. * @@ -60,12 +63,11 @@ enum class ErrorCode(val source: ErrorSource) { * "error": [ { "code": "STORAGE_LOCKED", "source": "CLIENT", "backendCode": null, "message": "..." } ] } * ``` * - * The constructor is private on purpose. An `internal` constructor still emits as JVM-public, so - * Java outside this module could build an error with no reasons and leave [first] throwing; private - * closes that hole. Callers construct through [of] or [fromWire]. + * Construct through [of] or [fromWire]. The internal companion is still reachable from Java, so the + * never-empty invariant is enforced in `init`, not by visibility. */ class OneSignalError private constructor( - error: List, + details: List, /** * The throwable behind the failure, when there was one. * @@ -83,13 +85,13 @@ class OneSignalError private constructor( * the copy itself cannot be emptied either. Java sees a plain `List` and `clear()` is one * keystroke away from `get()`; both would leave [first] throwing. */ - val error: List = Collections.unmodifiableList(error.toList()) + val details: List = Collections.unmodifiableList(details.toList()) init { // [first] is documented as always safe to read, and the wire projection of an empty error - // would claim failure while explaining nothing. Both factories guard this; the check is - // here so a future caller of the constructor cannot quietly break the invariant. - require(this.error.isNotEmpty()) { "OneSignalError requires at least one Detail." } + // would claim failure while explaining nothing. Every factory, including Java callers of the + // JVM-public companion, passes through here. + require(this.details.isNotEmpty()) { "OneSignalError requires at least one Detail." } } /** @@ -189,12 +191,12 @@ class OneSignalError private constructor( /** The first reason, which is the only one for every failure the SDK raises locally. */ val first: Detail - get() = error.first() + get() = details.first() /** Projects this error onto the cross-SDK wire shape consumed by the wrapper bridges. */ - fun toList(): List> = error.map { it.toMap() } + fun toList(): List> = details.map { it.toMap() } - override fun toString(): String = "OneSignalError(error=$error)" + override fun toString(): String = "OneSignalError(details=$details)" internal companion object { private val json = diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResult.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResult.kt index 120a95010e..a9f1248203 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResult.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResult.kt @@ -18,19 +18,18 @@ import org.json.JSONObject * The presence of [error] is what defines the outcome; [isSuccess] and the wire-level `success` * flag are both derived from it, so the two can never disagree. * - * The constructor is private on purpose. An `internal` constructor still emits as JVM-public, so - * Java outside this module could build an envelope that violates the either/or invariant; private - * closes that hole. Callers construct through [success], [failure], or [fromMap]. + * Construct through [success], [failure], or [fromMap]. The internal companion is still reachable + * from Java, so the either/or invariant is enforced in `init`, not by visibility. * - * From Kotlin: + * From Kotlin, inside a coroutine: * ```kotlin - * val result = OneSignal.login("user-123") + * val result = OneSignal.login("user-123", OneSignalUserProfile.builder().setEmail("a@b.co").build()) * if (result.isSuccess) println(result.data?.onesignalId) else println(result.error?.first?.code) * ``` * * From Java the generated accessors read naturally: * ```java - * if (result.isSuccess()) { result.getData(); } else { result.getError(); } + * if (result.isSuccess()) { result.getData(); } else { result.getError().getDetails(); } * ``` */ class OneSignalResult private constructor( diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResultData.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResultData.kt index 17e27f1134..c6e99a16af 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResultData.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalResultData.kt @@ -16,10 +16,9 @@ sealed interface OneSignalResultData { * The payloads themselves. * * Several of these start with no fields. That is intentional, and it is why none of them is a - * `data class` or an `object`: a regular class with an internal constructor can gain nullable or - * defaulted fields later without changing any signature, without a singleton blocking per-call - * state, and without silently altering generated equals/hashCode/toString/copy behavior that a - * customer may have come to depend on. + * `data class` or an `object`: a regular class can gain fields without a singleton blocking per-call + * state or a generated copy/equals changing under customers. The constructors are internal, which + * Java still sees as public: new fields change those JVM signatures, so they are not supported API. * * Field names here are also the wire keys, so they are additive-only: never renamed, never removed. */ diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt index bc9ca88cc3..c268564bda 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt @@ -1,20 +1,18 @@ package com.onesignal import com.onesignal.common.OneSignalUtils +import java.util.Collections /** * Profile applied at login. Email/SMS create or transfer that address onto this user; they do not replace other subscriptions. + * Build one with [builder]. There is no public constructor, so adding a field never breaks compiled callers. */ -class OneSignalUserProfile @JvmOverloads constructor( - email: String? = null, - sms: String? = null, - tags: Map = emptyMap(), - aliases: Map = emptyMap(), -) { - val email: String? = email?.takeIf { it.isNotBlank() } - val sms: String? = sms?.takeIf { it.isNotBlank() } - val tags: Map = tags.toMap() - val aliases: Map = aliases.toMap() +// Takes the Builder so the synthetic accessor's JVM signature stays fixed as fields are added. +class OneSignalUserProfile private constructor(builder: Builder) { + val email: String? = builder.email?.takeIf { it.isNotBlank() } + val sms: String? = builder.sms?.takeIf { it.isNotBlank() } + val tags: Map = Collections.unmodifiableMap(builder.tags.toMap()) + val aliases: Map = Collections.unmodifiableMap(builder.aliases.toMap()) internal val hasFields: Boolean get() = @@ -50,12 +48,16 @@ class OneSignalUserProfile @JvmOverloads constructor( override fun toString(): String = "OneSignalUserProfile(email=${redact(email)}, sms=${redact(sms)}, tags=${tags.size}, aliases=${aliases.size})" - /** Java builder. Use this instead of the constructor when only some fields are set. */ + /** Builds a [OneSignalUserProfile]. Unset fields stay empty. */ class Builder { - private var email: String? = null - private var sms: String? = null - private var tags: Map = emptyMap() - private var aliases: Map = emptyMap() + internal var email: String? = null + private set + internal var sms: String? = null + private set + internal var tags: Map = emptyMap() + private set + internal var aliases: Map = emptyMap() + private set /** Email to create or transfer onto this user at login. */ fun setEmail(email: String?) = apply { this.email = email } @@ -70,14 +72,22 @@ class OneSignalUserProfile @JvmOverloads constructor( fun setAliases(aliases: Map) = apply { this.aliases = aliases.toMap() } /** Builds the profile. */ - fun build() = OneSignalUserProfile(email, sms, tags, aliases) + fun build() = OneSignalUserProfile(this) } companion object { - /** Returns a new Java builder. */ + /** Returns a new builder. */ @JvmStatic fun builder() = Builder() + /** SDK-internal Kotlin shorthand; the mangled JVM name keeps it out of the public API. */ + internal operator fun invoke( + email: String? = null, + sms: String? = null, + tags: Map = emptyMap(), + aliases: Map = emptyMap(), + ) = Builder().setEmail(email).setSms(sms).setTags(tags).setAliases(aliases).build() + private fun redact(value: String?): String = if (value == null) "null" else "" } } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalErrorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalErrorTests.kt index c0552e55a2..6853a6aa98 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalErrorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalErrorTests.kt @@ -43,9 +43,9 @@ class OneSignalErrorTests : FunSpec({ val error = OneSignalError.fromBackendResponse(409, body) - error.error.map { it.backendCode } shouldBe listOf("user-1", "user-3") - error.error.map { it.message } shouldBe listOf("alias conflict", "invalid external id") - error.error.all { it.httpStatus == 409 } shouldBe true + error.details.map { it.backendCode } shouldBe listOf("user-1", "user-3") + error.details.map { it.message } shouldBe listOf("alias conflict", "invalid external id") + error.details.all { it.httpStatus == 409 } shouldBe true } test("fromBackendResponse keeps a plain body as the message") { diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalResultTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalResultTests.kt index caeefb18c6..1a53ad469b 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalResultTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalResultTests.kt @@ -80,7 +80,7 @@ class OneSignalResultTests : FunSpec({ ), ) - error.error.size shouldBe 2 + error.details.size shouldBe 2 error.first.backendCode shouldBe "user-1" error.toList().map { it["backendCode"] } shouldBe listOf("user-1", "user-3") } @@ -339,10 +339,10 @@ class OneSignalResultTests : FunSpec({ LoginData::fromMap, ) - restored.error!!.error.size shouldBe 2 - restored.error!!.error[0].code shouldBe ErrorCode.STORAGE_LOCKED - restored.error!!.error[1].code shouldBe ErrorCode.UNKNOWN - restored.error!!.error[1].message shouldBe "42" + restored.error!!.details.size shouldBe 2 + restored.error!!.details[0].code shouldBe ErrorCode.STORAGE_LOCKED + restored.error!!.details[1].code shouldBe ErrorCode.UNKNOWN + restored.error!!.details[1].message shouldBe "42" } // isSuccess reads error, callers read data. Neither or both leaves the two disagreeing. @@ -386,7 +386,7 @@ class OneSignalResultTests : FunSpec({ ) @Suppress("UNCHECKED_CAST") - shouldThrow { (restored.error!!.error as MutableList).clear() } + shouldThrow { (restored.error!!.details as MutableList).clear() } restored.error!!.first.code shouldBe ErrorCode.BACKEND_ERROR } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalUserProfileTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalUserProfileTests.kt index ee541afa12..a4a9fd1cd9 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalUserProfileTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalUserProfileTests.kt @@ -1,5 +1,6 @@ package com.onesignal +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe @@ -39,6 +40,13 @@ class OneSignalUserProfileTests : FunSpec({ profile.tags shouldBe mapOf("plan" to "pro") } + test("tags and aliases cannot be mutated through a Java cast") { + val profile = OneSignalUserProfile.builder().setTags(mapOf("plan" to "pro", "tier" to "gold")).setAliases(mapOf("crm" to "1", "erp" to "2")).build() + + shouldThrow { (profile.tags as MutableMap).put("plan", "x") } + shouldThrow { (profile.aliases as MutableMap).clear() } + } + test("toString redacts email and sms") { val profile = OneSignalUserProfile(email = "bob@example.com", sms = "+15555550100") From 906bb154a41136c0bbd6c63c75c33a6d472bac65 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 14 Oct 2026 13:09:50 -0700 Subject: [PATCH 06/20] fix: [SDK-5535] retry failed operations without blocking the queue (#2790) Co-authored-by: Claude Opus 5.5 --- .../java/com/onesignal/core/CoreModule.kt | 5 + .../core/internal/config/ConfigModel.kt | 30 ++- .../internal/device/IConnectivityMonitor.kt | 9 + .../device/impl/ConnectivityMonitor.kt | 61 ++++++ .../core/internal/http/impl/HttpClient.kt | 15 +- .../internal/operations/impl/OperationRepo.kt | 127 ++++++++---- .../operations/impl/OperationRetryTrigger.kt | 35 ++++ .../com/onesignal/core/internal/time/ITime.kt | 6 + .../onesignal/core/internal/time/impl/Time.kt | 2 + ...inUserFromSubscriptionOperationExecutor.kt | 2 +- .../executors/LoginUserOperationExecutor.kt | 2 +- .../device/ConnectivityMonitorTests.kt | 98 +++++++++ .../core/internal/http/HttpClientTests.kt | 79 +++++++- .../internal/operations/OperationRepoTests.kt | 189 +++++++++++++++++- .../operations/OperationRetryTriggerTests.kt | 83 ++++++++ .../RecoverFromDroppedLoginBugTests.kt | 3 +- .../java/com/onesignal/mocks/MockHelper.kt | 11 +- 17 files changed, 685 insertions(+), 72 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/device/IConnectivityMonitor.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/device/impl/ConnectivityMonitor.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRetryTrigger.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/device/ConnectivityMonitorTests.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRetryTriggerTests.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 8118671035..614903b8f2 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 @@ -16,9 +16,11 @@ import com.onesignal.core.internal.config.impl.FeatureFlagsRefreshService import com.onesignal.core.internal.config.impl.IdentityVerificationService import com.onesignal.core.internal.database.IDatabaseProvider import com.onesignal.core.internal.database.impl.DatabaseProvider +import com.onesignal.core.internal.device.IConnectivityMonitor import com.onesignal.core.internal.device.IDeviceService import com.onesignal.core.internal.device.IFidEnv import com.onesignal.core.internal.device.IInstallIdService +import com.onesignal.core.internal.device.impl.ConnectivityMonitor import com.onesignal.core.internal.device.impl.DeviceService import com.onesignal.core.internal.device.impl.FidEnvService import com.onesignal.core.internal.device.impl.InstallIdService @@ -34,6 +36,7 @@ import com.onesignal.core.internal.language.impl.LanguageContext import com.onesignal.core.internal.operations.IOperationRepo import com.onesignal.core.internal.operations.impl.OperationModelStore import com.onesignal.core.internal.operations.impl.OperationRepo +import com.onesignal.core.internal.operations.impl.OperationRetryTrigger import com.onesignal.core.internal.permissions.IRequestPermissionService import com.onesignal.core.internal.permissions.impl.RequestPermissionService import com.onesignal.core.internal.preferences.IPreferencesService @@ -69,6 +72,7 @@ internal class CoreModule : IModule { .provides() builder.register().provides() builder.register().provides() + builder.register().provides() builder.register