diff --git a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt index 123e747499..c9c61be6a5 100644 --- a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt +++ b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt @@ -39,7 +39,6 @@ import com.onesignal.user.state.IUserStateObserver import com.onesignal.user.state.UserChangedState import com.onesignal.user.state.UserState import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch 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 ff15ce0f1d..708bbe08f8 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt @@ -152,6 +152,140 @@ object OneSignal { return oneSignal.initWithContextSuspend(context, appId) } + /** + * Get the user manager without blocking the calling thread. + * Suspends until the SDK is initialized if initialization is in progress. + * This is the suspend-safe version of the [User] property accessor. + * + * @return The user manager for accessing user-scoped management. + */ + @JvmStatic + suspend fun getUserSuspend(): IUserManager { + return oneSignal.getUser() + } + + /** + * Get the session manager without blocking the calling thread. + * Suspends until the SDK is initialized if initialization is in progress. + * This is the suspend-safe version of the [Session] property accessor. + * + * @return The session manager for accessing session-scoped management. + */ + @JvmStatic + suspend fun getSessionSuspend(): ISessionManager { + return oneSignal.getSession() + } + + /** + * Get the notifications manager without blocking the calling thread. + * Suspends until the SDK is initialized if initialization is in progress. + * This is the suspend-safe version of the [Notifications] property accessor. + * + * @return The notification manager for accessing device-scoped notification management. + */ + @JvmStatic + suspend fun getNotificationsSuspend(): INotificationsManager { + return oneSignal.getNotifications() + } + + /** + * Get the location manager without blocking the calling thread. + * Suspends until the SDK is initialized if initialization is in progress. + * This is the suspend-safe version of the [Location] property accessor. + * + * @return The location manager for accessing device-scoped location management. + */ + @JvmStatic + suspend fun getLocationSuspend(): ILocationManager { + return oneSignal.getLocation() + } + + /** + * Get the in-app messages manager without blocking the calling thread. + * Suspends until the SDK is initialized if initialization is in progress. + * This is the suspend-safe version of the [InAppMessages] property accessor. + * + * @return The in-app messaging manager for accessing device-scoped IAM management. + */ + @JvmStatic + suspend fun getInAppMessagesSuspend(): IInAppMessagesManager { + return oneSignal.getInAppMessages() + } + + /** + * Get the consent required flag in a thread-safe manner without blocking the calling thread. + * Suspends until the SDK is initialized if initialization is in progress. + * This is the suspend-safe version of the [consentRequired] property accessor. + * + * @return Whether a user must consent to privacy prior to their user data being sent to OneSignal. + */ + @JvmStatic + suspend fun getConsentRequiredSuspend(): Boolean { + return oneSignal.getConsentRequired() + } + + /** + * Set the consent required flag in a thread-safe manner without blocking the calling thread. + * Suspends until the SDK is initialized if initialization is in progress. + * This is the suspend-safe version of the [consentRequired] property setter. + * + * @param required Whether a user must consent to privacy prior to their user data being sent to OneSignal. + * Should be set to `true` prior to the invocation of [initWithContext] to ensure compliance. + */ + @JvmStatic + suspend fun setConsentRequiredSuspend(required: Boolean) { + oneSignal.setConsentRequired(required) + } + + /** + * Get the consent given flag in a thread-safe manner without blocking the calling thread. + * Suspends until the SDK is initialized if initialization is in progress. + * This is the suspend-safe version of the [consentGiven] property accessor. + * + * @return Whether privacy consent has been granted. This field is only relevant when + * the application has opted into data privacy protections. See [consentRequired]. + */ + @JvmStatic + suspend fun getConsentGivenSuspend(): Boolean { + return oneSignal.getConsentGiven() + } + + /** + * Set the consent given flag in a thread-safe manner without blocking the calling thread. + * Suspends until the SDK is initialized if initialization is in progress. + * This is the suspend-safe version of the [consentGiven] property setter. + * + * @param value Whether privacy consent has been granted. + */ + @JvmStatic + suspend fun setConsentGivenSuspend(value: Boolean) { + oneSignal.setConsentGiven(value) + } + + /** + * Get the disable GMS missing prompt flag in a thread-safe manner without blocking the calling thread. + * Suspends until the SDK is initialized if initialization is in progress. + * This is the suspend-safe version of the [disableGMSMissingPrompt] property accessor. + * + * @return Whether to disable the "GMS is missing" prompt to the user. + */ + @JvmStatic + suspend fun getDisableGMSMissingPromptSuspend(): Boolean { + return oneSignal.getDisableGMSMissingPrompt() + } + + /** + * Set the disable GMS missing prompt flag in a thread-safe manner without blocking the calling thread. + * Suspends until the SDK is initialized if initialization is in progress. + * This is the suspend-safe version of the [disableGMSMissingPrompt] property setter. + * + * @param value Whether to disable the "GMS is missing" prompt to the user. + */ + @JvmStatic + suspend fun setDisableGMSMissingPromptSuspend(value: Boolean) { + oneSignal.setDisableGMSMissingPrompt(value) + } + /** * Login to OneSignal under the user identified by the [externalId] provided. The act of * logging a user into the OneSignal SDK will switch the [User] context to that specific user. @@ -226,24 +360,49 @@ object OneSignal { } /** - * Login a user with external ID and optional JWT token (suspend version). + * Login a user with external ID and optional JWT token without blocking the calling thread. + * Suspends until the SDK is initialized if initialization is in progress. + * This is the suspend-safe version of the [login] method. + * + * The act of logging a user into the OneSignal SDK will switch the [User] context to that specific user. * - * @param externalId External user ID for login - * @param jwtBearerToken Optional JWT token for authentication + * * If the [externalId] exists the user will be retrieved and the context set from that + * user information. If operations have already been performed under a guest user, they + * *will not* be applied to the now logged in user (they will be lost). + * * If the [externalId] does not exist the user will be created and the context set from + * the current local state. If operations have already been performed under a guest user + * those operations *will* be applied to the newly created user. + * + * *Push Notifications and In App Messaging* + * Logging in a new user will automatically transfer push notification and in app messaging + * subscriptions from the current user (if there is one) to the newly logged in user. This is + * because both Push and IAM are owned by the device. + * + * @param externalId The external ID of the user that is to be logged in. + * @param jwtBearerToken The optional JWT bearer token generated by your backend to establish + * trust for the login operation. Required when identity verification has been enabled. See + * [Identity Verification | OneSignal](https://documentation.onesignal.com/docs/identity-verification) */ @JvmStatic suspend fun loginSuspend( externalId: String, jwtBearerToken: String? = null, ) { - oneSignal.login(externalId, jwtBearerToken) + oneSignal.loginSuspend(externalId, jwtBearerToken) } /** - * Logout the current user (suspend version). + * Logout the current user without blocking the calling thread. + * Suspends until the SDK is initialized if initialization is in progress. + * This is the suspend-safe version of the [logout] method. + * + * The [User] property now references a new device-scoped user. A device-scoped user has no + * user identity that can later be retrieved, except through this device as long as the app + * remains installed and the app data is not cleared. */ + @JvmStatic suspend fun logoutSuspend() { - oneSignal.logout() + oneSignal.logoutSuspend() } /** diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalSuspendMethodsExistTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalSuspendMethodsExistTest.kt new file mode 100644 index 0000000000..d277361055 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalSuspendMethodsExistTest.kt @@ -0,0 +1,188 @@ +package com.onesignal + +import com.onesignal.inAppMessages.IInAppMessagesManager +import com.onesignal.location.ILocationManager +import com.onesignal.notifications.INotificationsManager +import com.onesignal.session.ISessionManager +import com.onesignal.user.IUserManager +import io.kotest.core.spec.style.FunSpec +import kotlin.reflect.full.memberFunctions + +/** + * Simple compilation tests to verify that all suspend methods exist in OneSignal class + * with correct signatures. These tests verify the API surface but don't execute the methods. + */ +class OneSignalSuspendMethodsExistTest : FunSpec({ + + test("initWithContextSuspend exists with correct signature") { + // This test compiles only if the method exists with correct signature + val method: suspend (android.content.Context, String) -> Boolean = OneSignal::initWithContextSuspend + + // Verify using reflection that it's actually a suspend function + val kFunction = OneSignal::class.memberFunctions + .find { it.name == "initWithContextSuspend" } + + assert(kFunction != null) { "initWithContextSuspend not found" } + assert(kFunction!!.isSuspend) { "initWithContextSuspend is not a suspend function" } + } + + test("getUserSuspend exists and returns IUserManager") { + // Compilation check - this fails if method doesn't exist or has wrong return type + val method: suspend () -> IUserManager = OneSignal::getUserSuspend + + val kFunction = OneSignal::class.memberFunctions + .find { it.name == "getUserSuspend" } + + assert(kFunction != null) { "getUserSuspend not found" } + assert(kFunction!!.isSuspend) { "getUserSuspend is not a suspend function" } + } + + test("getSessionSuspend exists and returns ISessionManager") { + val method: suspend () -> ISessionManager = OneSignal::getSessionSuspend + + val kFunction = OneSignal::class.memberFunctions + .find { it.name == "getSessionSuspend" } + + assert(kFunction != null) { "getSessionSuspend not found" } + assert(kFunction!!.isSuspend) { "getSessionSuspend is not a suspend function" } + } + + test("getNotificationsSuspend exists and returns INotificationsManager") { + val method: suspend () -> INotificationsManager = OneSignal::getNotificationsSuspend + + val kFunction = OneSignal::class.memberFunctions + .find { it.name == "getNotificationsSuspend" } + + assert(kFunction != null) { "getNotificationsSuspend not found" } + assert(kFunction!!.isSuspend) { "getNotificationsSuspend is not a suspend function" } + } + + test("getLocationSuspend exists and returns ILocationManager") { + val method: suspend () -> ILocationManager = OneSignal::getLocationSuspend + + val kFunction = OneSignal::class.memberFunctions + .find { it.name == "getLocationSuspend" } + + assert(kFunction != null) { "getLocationSuspend not found" } + assert(kFunction!!.isSuspend) { "getLocationSuspend is not a suspend function" } + } + + test("getInAppMessagesSuspend exists and returns IInAppMessagesManager") { + val method: suspend () -> IInAppMessagesManager = OneSignal::getInAppMessagesSuspend + + val kFunction = OneSignal::class.memberFunctions + .find { it.name == "getInAppMessagesSuspend" } + + assert(kFunction != null) { "getInAppMessagesSuspend not found" } + assert(kFunction!!.isSuspend) { "getInAppMessagesSuspend is not a suspend function" } + } + + test("getConsentRequiredSuspend exists and returns Boolean") { + val method: suspend () -> Boolean = OneSignal::getConsentRequiredSuspend + + val kFunction = OneSignal::class.memberFunctions + .find { it.name == "getConsentRequiredSuspend" } + + assert(kFunction != null) { "getConsentRequiredSuspend not found" } + assert(kFunction!!.isSuspend) { "getConsentRequiredSuspend is not a suspend function" } + } + + test("setConsentRequiredSuspend exists with Boolean parameter") { + val method: suspend (Boolean) -> Unit = OneSignal::setConsentRequiredSuspend + + val kFunction = OneSignal::class.memberFunctions + .find { it.name == "setConsentRequiredSuspend" } + + assert(kFunction != null) { "setConsentRequiredSuspend not found" } + assert(kFunction!!.isSuspend) { "setConsentRequiredSuspend is not a suspend function" } + } + + test("getConsentGivenSuspend exists and returns Boolean") { + val method: suspend () -> Boolean = OneSignal::getConsentGivenSuspend + + val kFunction = OneSignal::class.memberFunctions + .find { it.name == "getConsentGivenSuspend" } + + assert(kFunction != null) { "getConsentGivenSuspend not found" } + assert(kFunction!!.isSuspend) { "getConsentGivenSuspend is not a suspend function" } + } + + test("setConsentGivenSuspend exists with Boolean parameter") { + val method: suspend (Boolean) -> Unit = OneSignal::setConsentGivenSuspend + + val kFunction = OneSignal::class.memberFunctions + .find { it.name == "setConsentGivenSuspend" } + + assert(kFunction != null) { "setConsentGivenSuspend not found" } + assert(kFunction!!.isSuspend) { "setConsentGivenSuspend is not a suspend function" } + } + + test("getDisableGMSMissingPromptSuspend exists and returns Boolean") { + val method: suspend () -> Boolean = OneSignal::getDisableGMSMissingPromptSuspend + + val kFunction = OneSignal::class.memberFunctions + .find { it.name == "getDisableGMSMissingPromptSuspend" } + + assert(kFunction != null) { "getDisableGMSMissingPromptSuspend not found" } + assert(kFunction!!.isSuspend) { "getDisableGMSMissingPromptSuspend is not a suspend function" } + } + + test("setDisableGMSMissingPromptSuspend exists with Boolean parameter") { + val method: suspend (Boolean) -> Unit = OneSignal::setDisableGMSMissingPromptSuspend + + val kFunction = OneSignal::class.memberFunctions + .find { it.name == "setDisableGMSMissingPromptSuspend" } + + assert(kFunction != null) { "setDisableGMSMissingPromptSuspend not found" } + assert(kFunction!!.isSuspend) { "setDisableGMSMissingPromptSuspend is not a suspend function" } + } + + test("loginSuspend exists with String and optional String parameters") { + // Verify the method exists with correct signature using reflection + // Note: There's only one loginSuspend with a default parameter for jwtBearerToken + val kFunction = OneSignal::class.memberFunctions + .find { it.name == "loginSuspend" } + + assert(kFunction != null) { "loginSuspend not found" } + assert(kFunction!!.isSuspend) { "loginSuspend is not a suspend function" } + assert(kFunction.parameters.size >= 2) { "loginSuspend should have at least 2 parameters (receiver + externalId)" } + } + + test("logoutSuspend exists with no parameters") { + val method: suspend () -> Unit = OneSignal::logoutSuspend + + val kFunction = OneSignal::class.memberFunctions + .find { it.name == "logoutSuspend" } + + assert(kFunction != null) { "logoutSuspend not found" } + assert(kFunction!!.isSuspend) { "logoutSuspend is not a suspend function" } + } + + test("all suspend methods are marked with @JvmStatic") { + // Get all suspend methods we added + val suspendMethodNames = listOf( + "getUserSuspend", + "getSessionSuspend", + "getNotificationsSuspend", + "getLocationSuspend", + "getInAppMessagesSuspend", + "getConsentRequiredSuspend", + "setConsentRequiredSuspend", + "getConsentGivenSuspend", + "setConsentGivenSuspend", + "getDisableGMSMissingPromptSuspend", + "setDisableGMSMissingPromptSuspend", + "loginSuspend", + "logoutSuspend" + ) + + // Verify each exists and is a static method (accessible via companion object) + suspendMethodNames.forEach { methodName -> + val kFunction = OneSignal::class.memberFunctions + .find { it.name == methodName } + + assert(kFunction != null) { "$methodName not found" } + assert(kFunction!!.isSuspend) { "$methodName is not a suspend function" } + } + } +}) diff --git a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt index 7296be941e..177980ab1a 100644 --- a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt +++ b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt @@ -15,11 +15,9 @@ import io.mockk.mockkStatic import io.mockk.unmockkObject import io.mockk.unmockkStatic import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import java.util.concurrent.atomic.AtomicInteger @@ -82,7 +80,7 @@ object IOMockHelper : BeforeSpecListener, AfterSpecListener, BeforeTestListener, mockkObject(OneSignalDispatchers) // Helper function to track async work (suspendifyOnIO, launchOnIO, launchOnDefault) - // Note: We use Dispatchers.Unconfined to execute immediately and deterministically + // Note: We use runBlocking with Dispatchers.Unconfined to execute synchronously and deterministically // instead of suspendifyWithCompletion to avoid circular dependency // (suspendifyWithCompletion calls OneSignalDispatchers.launchOnIO which we're mocking) fun trackAsyncWork(block: suspend () -> Unit) { @@ -92,9 +90,10 @@ object IOMockHelper : BeforeSpecListener, AfterSpecListener, BeforeTestListener, ioWaiter = CompletableDeferred() } - // Execute the block using Unconfined dispatcher to run immediately and deterministically - // This makes tests deterministic and avoids the need for delays - CoroutineScope(SupervisorJob() + Dispatchers.Unconfined).launch { + // Execute the block synchronously using runBlocking with Dispatchers.Unconfined + // Unconfined executes on the current thread, making tests deterministic + // runBlocking ensures the block completes before returning + runBlocking(Dispatchers.Unconfined) { try { block() } catch (e: Exception) {