From 78df95b8b4b584ad6a72eca180261fb0ea6a17fe Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Tue, 18 Nov 2025 15:16:12 -0500 Subject: [PATCH 1/3] Revert "Merge pull request #2403 from OneSignal/kotlin1.9-update" This reverts commit a5079118cae1b96ba2ae11aa2e63832615f5cbcb, reversing changes made to 125da897258c4066df0dff83f25003767bfb6c1a. --- Examples/OneSignalDemo/app/build.gradle | 4 ---- OneSignalSDK/build.gradle | 2 +- .../com/onesignal/location/shadows/ShadowGoogleApiClient.kt | 2 +- .../internal/registration/impl/PushRegistratorHMS.kt | 1 + 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Examples/OneSignalDemo/app/build.gradle b/Examples/OneSignalDemo/app/build.gradle index c994d6615d..49bad6881b 100644 --- a/Examples/OneSignalDemo/app/build.gradle +++ b/Examples/OneSignalDemo/app/build.gradle @@ -64,10 +64,6 @@ android { } } - kotlinOptions { - jvmTarget = '1.8' - } - // Forced downgrade to Java 1.8 for compiling the application due to Android N error when building compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 diff --git a/OneSignalSDK/build.gradle b/OneSignalSDK/build.gradle index 41a491f000..33ce53b6c8 100644 --- a/OneSignalSDK/build.gradle +++ b/OneSignalSDK/build.gradle @@ -14,7 +14,7 @@ buildscript { huaweiAgconnectVersion = '1.9.1.304' huaweiHMSPushVersion = '6.3.0.304' huaweiHMSLocationVersion = '4.0.0.300' - kotlinVersion = '1.9.25' + kotlinVersion = '1.7.10' coroutinesVersion = '1.7.3' kotestVersion = '5.8.0' ioMockVersion = '1.13.2' diff --git a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/shadows/ShadowGoogleApiClient.kt b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/shadows/ShadowGoogleApiClient.kt index 78df8a3e6e..a3ab00b90a 100644 --- a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/shadows/ShadowGoogleApiClient.kt +++ b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/shadows/ShadowGoogleApiClient.kt @@ -63,7 +63,7 @@ class ShadowGoogleApiClient : GoogleApiClient() { connected = true } - override fun getClient(p0: Api.AnyClientKey): C { + override fun getClient(p0: Api.AnyClientKey): C { return object : Api.Client { override fun connect(p0: BaseGmsClient.ConnectionProgressReportCallbacks) { } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorHMS.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorHMS.kt index b568c34e9d..7d0c6ff9b8 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorHMS.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorHMS.kt @@ -47,6 +47,7 @@ internal class PushRegistratorHMS( return result!! } + @Synchronized @Throws(ApiException::class) private suspend fun getHMSTokenTask(context: Context): IPushRegistrator.RegisterResult { // Check required to prevent AGConnectServicesConfig or HmsInstanceId used below From 18e5c0785b52d7c63f62498087b16170f4fe7a6b Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Tue, 18 Nov 2025 15:16:30 -0500 Subject: [PATCH 2/3] Revert "chore: bump SDK_VERSION to 5.4.0 (#2395)" This reverts commit cf697da9174a4d4496aef45ca22e2cd8b166b2d4. --- Examples/OneSignalDemo/gradle.properties | 2 +- OneSignalSDK/gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Examples/OneSignalDemo/gradle.properties b/Examples/OneSignalDemo/gradle.properties index 18113c902d..7889808a54 100644 --- a/Examples/OneSignalDemo/gradle.properties +++ b/Examples/OneSignalDemo/gradle.properties @@ -17,4 +17,4 @@ android.enableJetifier=false # This is the name of the SDK to use when building your project. # This will be fed from the GitHub Actions workflow. -SDK_VERSION=5.4.0 \ No newline at end of file +SDK_VERSION=5.4.0-alpha-03 \ No newline at end of file diff --git a/OneSignalSDK/gradle.properties b/OneSignalSDK/gradle.properties index 51c56cce11..74fe2d213c 100644 --- a/OneSignalSDK/gradle.properties +++ b/OneSignalSDK/gradle.properties @@ -39,4 +39,4 @@ android.useAndroidX = true # This is the name of the SDK to use when building your project. # This will be fed from the GitHub Actions workflow. -SDK_VERSION=5.4.0 +SDK_VERSION=5.4.0-alpha-03 From 52a9326f7872ada47553517c3652e03506a5b6d1 Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Tue, 18 Nov 2025 15:20:48 -0500 Subject: [PATCH 3/3] Revert "improvement: Offloaded work on background threads. (#2394)" This reverts commit 6fa661dfb54e88d6865481ff81179695c36f9f6f. --- Examples/OneSignalDemo/app/build.gradle | 14 - .../app/src/huawei/AndroidManifest.xml | 2 +- .../app/src/main/AndroidManifest.xml | 2 +- .../sdktest/application/MainApplication.java | 8 - .../sdktest/application/MainApplicationKT.kt | 158 ---- .../sdktest/model/MainActivityViewModel.java | 17 +- Examples/OneSignalDemo/build.gradle | 1 - Examples/OneSignalDemo/gradle.properties | 2 +- OneSignalSDK/build.gradle | 24 +- OneSignalSDK/gradle.properties | 10 +- OneSignalSDK/onesignal/core/build.gradle | 17 +- .../src/main/java/com/onesignal/IOneSignal.kt | 105 +-- .../src/main/java/com/onesignal/OneSignal.kt | 42 +- .../common/threading/CompletionAwaiter.kt | 135 --- .../threading/OSPrimaryCoroutineScope.kt | 21 + .../common/threading/OneSignalDispatchers.kt | 187 ----- .../onesignal/common/threading/ThreadUtils.kt | 187 ++--- .../core/activities/PermissionsActivity.kt | 182 ++-- .../config/impl/ConfigModelStoreListener.kt | 6 +- .../core/internal/http/impl/HttpClient.kt | 8 +- .../internal/operations/impl/OperationRepo.kt | 11 +- .../AlertDialogPrepromptForAndroidSettings.kt | 54 +- .../permissions/PermissionsViewModel.kt | 224 ----- .../impl/RequestPermissionService.kt | 7 +- .../preferences/PreferencesExtensionV4.kt | 42 - .../preferences/impl/PreferencesService.kt | 11 +- .../core/internal/startup/StartupService.kt | 7 +- .../onesignal/core/services/SyncJobService.kt | 16 +- .../java/com/onesignal/internal/InitState.kt | 39 - .../com/onesignal/internal/OneSignalImp.kt | 786 +++++++++--------- .../session/internal/SessionManager.kt | 8 +- .../internal/influence/impl/ChannelTracker.kt | 256 +++--- .../outcomes/impl/OutcomeEventsController.kt | 7 +- .../outcomes/impl/OutcomeSourceBody.kt | 26 +- .../internal/session/impl/SessionListener.kt | 4 +- .../user/internal/AppIdResolution.kt | 37 - .../onesignal/user/internal/LoginHelper.kt | 54 -- .../onesignal/user/internal/LogoutHelper.kt | 37 - .../onesignal/user/internal/UserSwitcher.kt | 182 ---- .../internal/identity/IdentityModelStore.kt | 7 - .../migrations/RecoverFromDroppedLoginBug.kt | 6 +- ...inUserFromSubscriptionOperationExecutor.kt | 2 +- .../SubscriptionOperationExecutor.kt | 20 +- .../consistency/ConsistencyManagerTests.kt | 10 +- .../threading/CompletionAwaiterTests.kt | 363 -------- .../threading/OneSignalDispatchersTests.kt | 174 ---- .../common/threading/ThreadUtilsTests.kt | 344 -------- .../ThreadingPerformanceComparisonTests.kt | 527 ------------ .../ThreadingPerformanceDemoTests.kt | 236 ------ .../application/ApplicationServiceTests.kt | 119 ++- .../application/SDKInitSuspendTests.kt | 309 ------- .../core/internal/application/SDKInitTests.kt | 457 ---------- .../internal/operations/OperationRepoTests.kt | 273 ++---- .../permissions/PermissionsViewModelTests.kt | 165 ---- .../internal/startup/StartupServiceTests.kt | 4 +- .../onesignal/internal/OneSignalImpTests.kt | 209 +---- .../user/internal/AppIdHelperTests.kt | 259 ------ .../user/internal/LoginHelperTests.kt | 249 ------ .../user/internal/LogoutHelperTests.kt | 171 ---- .../user/internal/UserSwitcherTests.kt | 419 ---------- .../backend/UserBackendServiceTests.kt | 8 +- .../onesignal/in-app-messages/build.gradle | 6 +- .../internal/InAppMessagesManager.kt | 33 +- .../internal/display/impl/InAppMessageView.kt | 8 +- .../impl/OneSignalBounceInterpolator.kt | 2 +- .../internal/display/impl/WebViewManager.kt | 7 +- .../InAppMessagePreviewHandlerTests.kt | 16 +- OneSignalSDK/onesignal/location/build.gradle | 6 +- .../location/internal/LocationManager.kt | 6 +- .../location/internal/common/LocationUtils.kt | 2 +- .../controller/impl/GmsLocationController.kt | 14 +- .../controller/impl/HmsLocationController.kt | 14 +- .../onesignal/notifications/build.gradle | 6 +- .../NotificationOpenedActivityHMS.kt | 14 +- .../NotificationOpenedActivityBase.kt | 38 +- .../bridges/OneSignalHmsEventBridge.kt | 69 +- .../internal/NotificationsManager.kt | 10 +- .../internal/common/NotificationHelper.kt | 2 +- .../data/impl/NotificationRepository.kt | 18 +- .../impl/NotificationGenerationProcessor.kt | 8 +- .../impl/NotificationGenerationWorkManager.kt | 14 +- .../impl/NotificationLifecycleService.kt | 27 +- .../listeners/DeviceRegistrationListener.kt | 6 +- .../impl/NotificationPermissionController.kt | 7 +- .../internal/pushtoken/PushTokenManager.kt | 2 +- .../impl/ReceiveReceiptWorkManager.kt | 7 +- .../impl/NotificationRestoreWorkManager.kt | 7 +- .../notifications/receivers/BootUpReceiver.kt | 20 +- .../receivers/FCMBroadcastReceiver.kt | 41 +- .../receivers/NotificationDismissReceiver.kt | 25 +- .../receivers/UpgradeReceiver.kt | 26 +- .../services/ADMMessageHandler.kt | 26 +- .../services/ADMMessageHandlerJob.kt | 30 +- .../NotificationGenerationProcessorTests.kt | 7 +- .../NotificationLifecycleServiceTests.kt | 8 +- README.md | 2 +- 96 files changed, 1217 insertions(+), 6584 deletions(-) delete mode 100644 Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CompletionAwaiter.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OSPrimaryCoroutineScope.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/PreferencesExtensionV4.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/InitState.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/AppIdResolution.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserSwitcher.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/CompletionAwaiterTests.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/ThreadUtilsTests.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/ThreadingPerformanceComparisonTests.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/ThreadingPerformanceDemoTests.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/AppIdHelperTests.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserSwitcherTests.kt diff --git a/Examples/OneSignalDemo/app/build.gradle b/Examples/OneSignalDemo/app/build.gradle index 49bad6881b..69544d5e6a 100644 --- a/Examples/OneSignalDemo/app/build.gradle +++ b/Examples/OneSignalDemo/app/build.gradle @@ -1,6 +1,5 @@ plugins { id 'com.android.application' - id 'kotlin-android' } android { @@ -49,18 +48,6 @@ android { // signingConfig null // productFlavors.huawei.signingConfig signingConfigs.huawei debuggable true - // Note: profileable is automatically enabled when debuggable=true - // Enable method tracing for detailed performance analysis - testCoverageEnabled false - } - // Profileable release build for performance testing - profileable { - initWith release - debuggable false - profileable true - minifyEnabled false - signingConfig signingConfigs.debug - matchingFallbacks = ['release'] } } @@ -87,7 +74,6 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' implementation 'androidx.multidex:multidex:2.0.1' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.appcompat:appcompat:1.5.1' diff --git a/Examples/OneSignalDemo/app/src/huawei/AndroidManifest.xml b/Examples/OneSignalDemo/app/src/huawei/AndroidManifest.xml index 6778b0ea3b..679bb4e3a9 100644 --- a/Examples/OneSignalDemo/app/src/huawei/AndroidManifest.xml +++ b/Examples/OneSignalDemo/app/src/huawei/AndroidManifest.xml @@ -5,7 +5,7 @@ package="com.onesignal.sdktest"> + android:name=".application.MainApplication"> { + boolean isSubscriptionEnabled = !pushSubscriptionEnabledSwitch.isChecked(); + pushSubscriptionEnabledSwitch.setChecked(isSubscriptionEnabled); + }); + // Add a listener to toggle the push notification enablement for the push subscription. pushSubscriptionEnabledSwitch.setOnClickListener(v -> { IPushSubscription subscription = OneSignal.getUser().getPushSubscription(); @@ -809,12 +811,7 @@ private void setupSubscriptionSwitch() { private void setupPromptPushButton() { promptPushButton.setOnClickListener(v -> { - ExecutorService executor = Executors.newSingleThreadExecutor(); - @SuppressLint({"NewApi", "LocalSuppress"}) CompletableFuture future = CompletableFuture.runAsync(() -> { - OneSignal.getNotifications().requestPermission(true, Continue.none()); - }, executor); - future.join(); // Waits for the task to complete - executor.shutdown(); + OneSignal.getUser().getPushSubscription().optIn(); }); } diff --git a/Examples/OneSignalDemo/build.gradle b/Examples/OneSignalDemo/build.gradle index 67f3447300..3b722ccce5 100644 --- a/Examples/OneSignalDemo/build.gradle +++ b/Examples/OneSignalDemo/build.gradle @@ -13,7 +13,6 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:8.8.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" classpath 'com.google.gms:google-services:4.3.10' classpath 'com.huawei.agconnect:agcp:1.9.1.304' diff --git a/Examples/OneSignalDemo/gradle.properties b/Examples/OneSignalDemo/gradle.properties index 7889808a54..80e9d1d8f8 100644 --- a/Examples/OneSignalDemo/gradle.properties +++ b/Examples/OneSignalDemo/gradle.properties @@ -17,4 +17,4 @@ android.enableJetifier=false # This is the name of the SDK to use when building your project. # This will be fed from the GitHub Actions workflow. -SDK_VERSION=5.4.0-alpha-03 \ No newline at end of file +SDK_VERSION=5.1.38 diff --git a/OneSignalSDK/build.gradle b/OneSignalSDK/build.gradle index 33ce53b6c8..b4ca794929 100644 --- a/OneSignalSDK/build.gradle +++ b/OneSignalSDK/build.gradle @@ -15,13 +15,9 @@ buildscript { huaweiHMSPushVersion = '6.3.0.304' huaweiHMSLocationVersion = '4.0.0.300' kotlinVersion = '1.7.10' - coroutinesVersion = '1.7.3' kotestVersion = '5.8.0' ioMockVersion = '1.13.2' - // AndroidX Lifecycle and Activity versions - lifecycleVersion = '2.6.2' - activityVersion = '1.7.2' - ktlintVersion = '0.50.0' // Used by Spotless for Kotlin formatting (compatible with Kotlin 1.7.10) + ktlintVersion = '1.0.1' // Used by Spotless for Kotlin formatting spotlessVersion = '6.25.0' tdunningJsonForTest = '1.0' // DO NOT upgrade for tests, using an old version so it matches AOSP @@ -61,24 +57,6 @@ allprojects { // Huawei maven maven { url 'https://developer.huawei.com/repo/' } } - - // Force all modules to use the same Kotlin version - configurations.all { - resolutionStrategy { - force "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" - force "org.jetbrains.kotlin:kotlin-stdlib-common:$kotlinVersion" - - // Exclude deprecated jdk7/jdk8 variants - eachDependency { details -> - if (details.requested.group == 'org.jetbrains.kotlin') { - if (details.requested.name == 'kotlin-stdlib-jdk7' || - details.requested.name == 'kotlin-stdlib-jdk8') { - details.useTarget "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" - } - } - } - } - } } // Aggregate Detekt tasks at the root so CI can call them once diff --git a/OneSignalSDK/gradle.properties b/OneSignalSDK/gradle.properties index 74fe2d213c..d83a6c6a25 100644 --- a/OneSignalSDK/gradle.properties +++ b/OneSignalSDK/gradle.properties @@ -23,13 +23,7 @@ # Remove when creating an .aar build. #android.enableAapt2=false -org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError - -# Gradle daemon optimization -org.gradle.daemon=true -org.gradle.parallel=true -org.gradle.caching=true -org.gradle.configureondemand=true +org.gradle.jvmargs=-Xmx1536m # Enables D8 for all modules. android.enableD8 = true @@ -39,4 +33,4 @@ android.useAndroidX = true # This is the name of the SDK to use when building your project. # This will be fed from the GitHub Actions workflow. -SDK_VERSION=5.4.0-alpha-03 +SDK_VERSION=5.1.38 diff --git a/OneSignalSDK/onesignal/core/build.gradle b/OneSignalSDK/onesignal/core/build.gradle index b0f2580711..6f90bb1224 100644 --- a/OneSignalSDK/onesignal/core/build.gradle +++ b/OneSignalSDK/onesignal/core/build.gradle @@ -34,8 +34,7 @@ android { testOptions { unitTests.all { maxParallelForks 1 - maxHeapSize '3072m' - jvmArgs '-XX:MaxMetaspaceSize=256m', '-XX:+UseG1GC', '-XX:+UseStringDeduplication' + maxHeapSize '2048m' } unitTests { includeAndroidResources = true @@ -70,14 +69,9 @@ ext { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" - - // AndroidX Lifecycle and Activity - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion" - implementation "androidx.activity:activity-ktx:$activityVersion" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" compileOnly('com.amazon.device:amazon-appstore-sdk:[3.0.1, 3.0.99]') @@ -94,14 +88,13 @@ dependencies { testImplementation("io.kotest:kotest-runner-junit5-jvm:$kotestVersion") testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") testImplementation("io.kotest:kotest-property:$kotestVersion") - testImplementation("org.robolectric:robolectric:4.10.3") + testImplementation("org.robolectric:robolectric:4.8.1") // kotest-extensions-android allows Robolectric to work with Kotest via @RobolectricTest testImplementation("br.com.colman:kotest-extensions-android:0.1.1") testImplementation("androidx.test:core-ktx:1.4.0") testImplementation("androidx.test:core:1.4.0") testImplementation("io.mockk:mockk:$ioMockVersion") testImplementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") // com.tdunning:json is needed for non-Robolectric tests. testImplementation("com.tdunning:json:$tdunningJsonForTest") 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 e20ddfc2ac..cb707e4fe4 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt @@ -85,16 +85,9 @@ interface IOneSignal { */ fun initWithContext( context: Context, - appId: String, + appId: String?, ): Boolean - /** - * Initialize the OneSignal SDK, suspend until initialization is completed - * - * @param context The Android context the SDK should use. - */ - suspend fun initWithContext(context: Context): Boolean - /** * 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. @@ -130,100 +123,4 @@ interface IOneSignal { * data is not cleared. */ fun logout() - - // Suspend versions of property accessors and methods to avoid blocking threads - - /** - * Initialize the OneSignal SDK, suspend until initialization is completed - * - * @param context The Android context the SDK should use. - * @param appId The application ID the OneSignal SDK is bound to. - * - * @return true if the SDK could be successfully initialized, false otherwise. - */ - suspend fun initWithContextSuspend( - context: Context, - appId: String? = null, - ): Boolean - - /** - * Get the session manager without blocking the calling thread. - * Suspends until the SDK is initialized. - */ - suspend fun getSession(): ISessionManager - - /** - * Get the notifications manager without blocking the calling thread. - * Suspends until the SDK is initialized. - */ - suspend fun getNotifications(): INotificationsManager - - /** - * Get the location manager without blocking the calling thread. - * Suspends until the SDK is initialized. - */ - suspend fun getLocation(): ILocationManager - - /** - * Get the in-app messages manager without blocking the calling thread. - * Suspends until the SDK is initialized. - */ - suspend fun getInAppMessages(): IInAppMessagesManager - - /** - * Get the user manager without blocking the calling thread. - * Suspends until the SDK is initialized. - */ - suspend fun getUser(): IUserManager - - // Suspend versions of configuration properties for thread-safe access - - /** - * Get the consent required flag in a thread-safe manner. - */ - suspend fun getConsentRequired(): Boolean - - /** - * Set the consent required flag in a thread-safe manner. - */ - suspend fun setConsentRequired(required: Boolean) - - /** - * Get the consent given flag in a thread-safe manner. - */ - suspend fun getConsentGiven(): Boolean - - /** - * Set the consent given flag in a thread-safe manner. - */ - suspend fun setConsentGiven(value: Boolean) - - /** - * Get the disable GMS missing prompt flag in a thread-safe manner. - */ - suspend fun getDisableGMSMissingPrompt(): Boolean - - /** - * Set the disable GMS missing prompt flag in a thread-safe manner. - */ - suspend fun setDisableGMSMissingPrompt(value: Boolean) - - /** - * Login a user with external ID and optional JWT token (suspend version). - * Handles initialization automatically. - * - * @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) - */ - suspend fun loginSuspend( - externalId: String, - jwtBearerToken: String? = null, - ) - - /** - * Logout the current user (suspend version). - */ - suspend fun logoutSuspend() } 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..580cd63252 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt @@ -135,23 +135,6 @@ object OneSignal { oneSignal.initWithContext(context, appId) } - /** - * Initialize the OneSignal SDK asynchronously. This should be called during startup of the application. - * This method provides a suspended version that returns a boolean indicating success. - * Uses Dispatchers.IO internally to prevent ANRs and optimize for I/O operations. - * - * @param context Application context is recommended for SDK operations - * @param appId The application ID the OneSignal SDK is bound to. - * @return Boolean indicating if initialization was successful. - */ - @JvmStatic - suspend fun initWithContextSuspend( - context: Context, - appId: String? = null, - ): Boolean { - return oneSignal.initWithContextSuspend(context, appId) - } - /** * 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. @@ -221,29 +204,8 @@ object OneSignal { * THIS IS AN INTERNAL INTERFACE AND SHOULD NOT BE USED DIRECTLY. */ @JvmStatic - suspend fun initWithContext(context: Context): Boolean { - return oneSignal.initWithContext(context) - } - - /** - * Login a user with external ID and optional JWT token (suspend version). - * - * @param externalId External user ID for login - * @param jwtBearerToken Optional JWT token for authentication - */ - @JvmStatic - suspend fun loginSuspend( - externalId: String, - jwtBearerToken: String? = null, - ) { - oneSignal.login(externalId, jwtBearerToken) - } - - /** - * Logout the current user (suspend version). - */ - suspend fun logoutSuspend() { - oneSignal.logout() + fun initWithContext(context: Context): Boolean { + return oneSignal.initWithContext(context, null) } /** diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CompletionAwaiter.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CompletionAwaiter.kt deleted file mode 100644 index 880556393b..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CompletionAwaiter.kt +++ /dev/null @@ -1,135 +0,0 @@ -package com.onesignal.common.threading - -import com.onesignal.common.AndroidUtils -import com.onesignal.common.threading.OneSignalDispatchers.BASE_THREAD_NAME -import com.onesignal.debug.internal.logging.Logging -import kotlinx.coroutines.CompletableDeferred -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -/** - * A unified completion awaiter that supports both blocking and suspend-based waiting. - * This class allows both legacy blocking code and modern coroutines to wait for the same event. - * - * It is designed for scenarios where certain tasks, such as SDK initialization, must finish - * before continuing. When used on the main/UI thread for blocking operations, it applies a - * shorter timeout and logs warnings to prevent ANR errors. - * - * PERFORMANCE NOTE: Having both blocking (CountDownLatch) and suspend (Channel) mechanisms - * in place is very low cost and should not hurt performance. The overhead is minimal: - * - CountDownLatch: ~32 bytes, optimized for blocking threads - * - Channel: ~64 bytes, optimized for coroutine suspension - * - Total overhead: <100 bytes per awaiter instance - * - Notification cost: Two simple operations (countDown + trySend) - * - * This dual approach provides optimal performance for each use case rather than forcing - * a one-size-fits-all solution that would be suboptimal for both scenarios. - * - * Usage: - * val awaiter = CompletionAwaiter("OneSignal SDK Init") - * - * // For blocking code: - * awaiter.await() - * - * // For suspend code: - * awaiter.awaitSuspend() - * - * // When complete: - * awaiter.complete() - */ -class CompletionAwaiter( - private val componentName: String = "Component", -) { - companion object { - const val DEFAULT_TIMEOUT_MS = 30_000L // 30 seconds - const val ANDROID_ANR_TIMEOUT_MS = 4_800L // Conservative ANR threshold - } - - private val latch = CountDownLatch(1) - private val suspendCompletion = CompletableDeferred() - - /** - * Completes the awaiter, unblocking both blocking and suspend callers. - */ - fun complete() { - latch.countDown() - suspendCompletion.complete(Unit) - } - - /** - * Wait for completion using blocking approach with an optional timeout. - * - * @param timeoutMs Timeout in milliseconds, defaults to context-appropriate timeout - * @return true if completed before timeout, false otherwise. - */ - fun await(timeoutMs: Long = getDefaultTimeout()): Boolean { - val completed = - try { - latch.await(timeoutMs, TimeUnit.MILLISECONDS) - } catch (e: InterruptedException) { - Logging.warn("Interrupted while waiting for $componentName", e) - logAllThreads() - false - } - - if (!completed) { - val message = createTimeoutMessage(timeoutMs) - Logging.warn(message) - } - - return completed - } - - /** - * Wait for completion using suspend approach (non-blocking for coroutines). - * This method will suspend the current coroutine until completion is signaled. - */ - suspend fun awaitSuspend() { - suspendCompletion.await() - } - - private fun getDefaultTimeout(): Long { - return if (AndroidUtils.isRunningOnMainThread()) ANDROID_ANR_TIMEOUT_MS else DEFAULT_TIMEOUT_MS - } - - private fun createTimeoutMessage(timeoutMs: Long): String { - return if (AndroidUtils.isRunningOnMainThread()) { - "Timeout waiting for $componentName after ${timeoutMs}ms on the main thread. " + - "This can cause ANRs. Consider calling from a background thread." - } else { - "Timeout waiting for $componentName after ${timeoutMs}ms." - } - } - - private fun logAllThreads(): String { - val sb = StringBuilder() - - // Add OneSignal dispatcher status first (fast) - sb.append("=== OneSignal Dispatchers Status ===\n") - sb.append(OneSignalDispatchers.getStatus()) - sb.append("=== OneSignal Dispatchers Performance ===\n") - sb.append(OneSignalDispatchers.getPerformanceMetrics()) - sb.append("\n\n") - - // Add lightweight thread info (fast) - sb.append("=== All Threads Summary ===\n") - val threads = Thread.getAllStackTraces().keys - for (thread in threads) { - sb.append("Thread: ${thread.name} [${thread.state}] ${if (thread.isDaemon) "(daemon)" else ""}\n") - } - - // Only add full stack traces for OneSignal threads (much faster) - sb.append("\n=== OneSignal Thread Details ===\n") - for ((thread, stack) in Thread.getAllStackTraces()) { - if (thread.name.startsWith(BASE_THREAD_NAME)) { - sb.append("Thread: ${thread.name} [${thread.state}]\n") - for (element in stack.take(10)) { // Limit to first 10 frames - sb.append("\tat $element\n") - } - sb.append("\n") - } - } - - return sb.toString() - } -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OSPrimaryCoroutineScope.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OSPrimaryCoroutineScope.kt new file mode 100644 index 0000000000..78eee700a5 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OSPrimaryCoroutineScope.kt @@ -0,0 +1,21 @@ +package com.onesignal.common.threading + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext + +object OSPrimaryCoroutineScope { + // CoroutineScope tied to the main thread + private val mainScope = CoroutineScope(newSingleThreadContext(name = "OSPrimaryCoroutineScope")) + + /** + * Executes the given [block] on the OS primary coroutine scope. + */ + fun execute(block: suspend () -> Unit) { + mainScope.launch { + block() + } + } + + suspend fun waitForIdle() = mainScope.launch { }.join() +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt deleted file mode 100644 index 19c7b1ddde..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt +++ /dev/null @@ -1,187 +0,0 @@ -package com.onesignal.common.threading - -import com.onesignal.debug.internal.logging.Logging -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.ThreadFactory -import java.util.concurrent.ThreadPoolExecutor -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger - -/** - * Optimized threading manager for the OneSignal SDK. - * - * Performance optimizations: - * - Lazy initialization to reduce startup overhead - * - Custom thread pools for both IO and Default operations - * - Optimized thread pool configuration (smaller pools) - * - Small bounded queues (10 tasks) to prevent memory bloat - * - Reduced context switching overhead - * - Efficient thread management with controlled resource usage - */ -internal object OneSignalDispatchers { - // Optimized pool sizes based on CPU cores and workload analysis - private const val IO_CORE_POOL_SIZE = 2 // Increased for better concurrency - private const val IO_MAX_POOL_SIZE = 3 // Increased for better concurrency - private const val DEFAULT_CORE_POOL_SIZE = 2 // Optimal for CPU operations - private const val DEFAULT_MAX_POOL_SIZE = 3 // Slightly larger for CPU operations - private const val KEEP_ALIVE_TIME_SECONDS = - 30L // Keep threads alive longer to reduce recreation - private const val QUEUE_CAPACITY = - 10 // Small queue that allows up to 10 tasks to wait in queue when all threads are busy - internal const val BASE_THREAD_NAME = "OneSignal" // Base thread name prefix - private const val IO_THREAD_NAME_PREFIX = - "$BASE_THREAD_NAME-IO" // Thread name prefix for I/O operations - private const val DEFAULT_THREAD_NAME_PREFIX = - "$BASE_THREAD_NAME-Default" // Thread name prefix for CPU operations - - private class OptimizedThreadFactory( - private val namePrefix: String, - private val priority: Int = Thread.NORM_PRIORITY, - ) : ThreadFactory { - private val threadNumber = AtomicInteger(1) - - override fun newThread(r: Runnable): Thread { - val thread = Thread(r, "$namePrefix-${threadNumber.getAndIncrement()}") - thread.isDaemon = true - thread.priority = priority - return thread - } - } - - private val ioExecutor: ThreadPoolExecutor by lazy { - try { - ThreadPoolExecutor( - IO_CORE_POOL_SIZE, - IO_MAX_POOL_SIZE, - KEEP_ALIVE_TIME_SECONDS, - TimeUnit.SECONDS, - LinkedBlockingQueue(QUEUE_CAPACITY), - OptimizedThreadFactory( - namePrefix = IO_THREAD_NAME_PREFIX, - priority = Thread.NORM_PRIORITY - 1, - // Slightly lower priority for I/O tasks - ), - ).apply { - allowCoreThreadTimeOut(false) // Keep core threads alive - } - } catch (e: Exception) { - Logging.error("OneSignalDispatchers: Failed to create IO executor: ${e.message}") - throw e // Let the dispatcher fallback handle this - } - } - - private val defaultExecutor: ThreadPoolExecutor by lazy { - try { - ThreadPoolExecutor( - DEFAULT_CORE_POOL_SIZE, - DEFAULT_MAX_POOL_SIZE, - KEEP_ALIVE_TIME_SECONDS, - TimeUnit.SECONDS, - LinkedBlockingQueue(QUEUE_CAPACITY), - OptimizedThreadFactory(DEFAULT_THREAD_NAME_PREFIX), - ).apply { - allowCoreThreadTimeOut(false) // Keep core threads alive - } - } catch (e: Exception) { - Logging.error("OneSignalDispatchers: Failed to create Default executor: ${e.message}") - throw e // Let the dispatcher fallback handle this - } - } - - // Dispatchers and scopes - also lazy initialized - val IO: CoroutineDispatcher by lazy { - try { - ioExecutor.asCoroutineDispatcher() - } catch (e: Exception) { - Logging.error("OneSignalDispatchers: Using fallback Dispatchers.IO dispatcher: ${e.message}") - Dispatchers.IO - } - } - - val Default: CoroutineDispatcher by lazy { - try { - defaultExecutor.asCoroutineDispatcher() - } catch (e: Exception) { - Logging.error("OneSignalDispatchers: Using fallback Dispatchers.Default dispatcher: ${e.message}") - Dispatchers.Default - } - } - - private val IOScope: CoroutineScope by lazy { - CoroutineScope(SupervisorJob() + IO) - } - - private val DefaultScope: CoroutineScope by lazy { - CoroutineScope(SupervisorJob() + Default) - } - - fun launchOnIO(block: suspend () -> Unit): Job { - return IOScope.launch { block() } - } - - fun launchOnDefault(block: suspend () -> Unit): Job { - return DefaultScope.launch { block() } - } - - internal fun getPerformanceMetrics(): String { - return try { - """ - OneSignalDispatchers Performance Metrics: - - IO Pool: ${ioExecutor.activeCount}/${ioExecutor.corePoolSize} active/core threads - - IO Queue: ${ioExecutor.queue.size} pending tasks - - Default Pool: ${defaultExecutor.activeCount}/${defaultExecutor.corePoolSize} active/core threads - - Default Queue: ${defaultExecutor.queue.size} pending tasks - - Total completed tasks: ${ioExecutor.completedTaskCount + defaultExecutor.completedTaskCount} - - Memory usage: ~${(ioExecutor.activeCount + defaultExecutor.activeCount) * 1024}KB (thread stacks, ~1MB each) - """.trimIndent() - } catch (e: Exception) { - "OneSignalDispatchers not initialized or using fallback dispatchers ${e.message}" - } - } - - internal fun getStatus(): String { - val ioExecutorStatus = - try { - if (ioExecutor.isShutdown) "Shutdown" else "Active" - } catch (e: Exception) { - "ioExecutor Not initialized ${e.message ?: "Unknown error"}" - } - - val defaultExecutorStatus = - try { - if (defaultExecutor.isShutdown) "Shutdown" else "Active" - } catch (e: Exception) { - "defaultExecutor Not initialized ${e.message ?: "Unknown error"}" - } - - val ioScopeStatus = - try { - if (IOScope.isActive) "Active" else "Cancelled" - } catch (e: Exception) { - "IOScope Not initialized ${e.message ?: "Unknown error"}" - } - - val defaultScopeStatus = - try { - if (DefaultScope.isActive) "Active" else "Cancelled" - } catch (e: Exception) { - "DefaultScope Not initialized ${e.message ?: "Unknown error"}" - } - - return """ - OneSignalDispatchers Status: - - IO Executor: $ioExecutorStatus - - Default Executor: $defaultExecutorStatus - - IO Scope: $ioScopeStatus - - Default Scope: $defaultScopeStatus - """.trimIndent() - } -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/ThreadUtils.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/ThreadUtils.kt index 2f46015721..504a0e4339 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/ThreadUtils.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/ThreadUtils.kt @@ -2,30 +2,55 @@ package com.onesignal.common.threading import com.onesignal.debug.internal.logging.Logging import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import kotlin.concurrent.thread /** - * Modernized ThreadUtils that leverages OneSignalDispatchers for better thread management. + * Allows a non-suspending function to create a scope that can + * call suspending functions. This is a blocking call, which + * means it will not return until the suspending scope has been + * completed. The current thread will also be blocked until + * the suspending scope has completed. * - * This file provides utilities for bridging non-suspending code with suspending functions, - * now using the centralized OneSignal dispatcher system for improved resource management - * and consistent threading behavior across the SDK. + * Note: This can be very dangerous!! Blocking a thread (especially + * the main thread) has the potential for a deadlock. Consider this + * code that is running on the main thread: * - * @see OneSignalDispatchers + * ``` + * suspendifyOnThread { + * withContext(Dispatchers.Main) { + * } + * } + * ``` * + * The `withContext` will suspend until the main thread is available, but + * the main thread is parked via this `suspendifyBlocking`. This will + * never recover. + */ +fun suspendifyBlocking(block: suspend () -> Unit) { + runBlocking { + block() + } +} + +/** * Allows a non suspending function to create a scope that can * call suspending functions while on the main thread. This is a nonblocking call, * the scope will start on a background thread and block as it switches * over to the main thread context. This will return immediately!!! - * - * @param block A suspending lambda to be executed on the background thread. - * This is where you put your suspending code. - * */ fun suspendifyOnMain(block: suspend () -> Unit) { - OneSignalDispatchers.launchOnIO { - withContext(Dispatchers.Main) { block() } + thread { + try { + runBlocking { + withContext(Dispatchers.Main) { + block() + } + } + } catch (e: Exception) { + Logging.error("Exception on thread with switch to main", e) + } } } @@ -33,136 +58,64 @@ fun suspendifyOnMain(block: suspend () -> Unit) { * Allows a non suspending function to create a scope that can * call suspending functions. This is a nonblocking call, which * means the scope will run on a background thread. This will - * return immediately!!! Also provides an optional onComplete. - ** - * @param block A suspending lambda to be executed on the background thread. - * This is where you put your suspending code. - * - * @param onComplete An optional lambda that will be invoked on the same - * background thread after [block] has finished executing. - * Useful for cleanup or follow-up logic. + * return immediately!!! */ -fun suspendifyOnIO( +fun suspendifyOnThread( + priority: Int = -1, block: suspend () -> Unit, - onComplete: (() -> Unit)? = null, ) { - suspendifyWithCompletion(useIO = true, block = block, onComplete = onComplete) + suspendifyOnThread(priority, block, null) } /** * Allows a non suspending function to create a scope that can * call suspending functions. This is a nonblocking call, which * means the scope will run on a background thread. This will - * return immediately!!! - * Uses OneSignal's centralized thread management for better resource control. + * return immediately!!! Also provides an optional onComplete. * - * @param block The suspending code to execute + * @param priority The priority of the background thread. Default is -1. + * Higher values indicate higher thread priority. * - */ -fun suspendifyOnIO(block: suspend () -> Unit) { - suspendifyWithCompletion(useIO = true, block = block, onComplete = null) -} - -/** - * Modern utility for executing suspending code on the default dispatcher. - * Uses OneSignal's centralized thread management for CPU-intensive operations. - * - * @param block The suspending code to execute - */ -fun suspendifyOnDefault(block: suspend () -> Unit) { - suspendifyWithCompletion(useIO = false, block = block, onComplete = null) -} - -/** - * Modern utility for executing suspending code with completion callback. - * Uses OneSignal's centralized thread management for better resource control. + * @param block A suspending lambda to be executed on the background thread. + * This is where you put your suspending code. * - * @param useIO Whether to use IO scope (true) or Default scope (false) - * @param block The suspending code to execute - * @param onComplete Optional callback to execute after completion - */ -fun suspendifyWithCompletion( - useIO: Boolean = true, + * @param onComplete An optional lambda that will be invoked on the same + * background thread after [block] has finished executing. + * Useful for cleanup or follow-up logic. + **/ +fun suspendifyOnThread( + priority: Int = -1, block: suspend () -> Unit, onComplete: (() -> Unit)? = null, ) { - if (useIO) { - OneSignalDispatchers.launchOnIO { - try { - block() - onComplete?.invoke() - } catch (e: Exception) { - Logging.error("Exception in suspendifyWithCompletion", e) - } - } - } else { - OneSignalDispatchers.launchOnDefault { - try { - block() - onComplete?.invoke() - } catch (e: Exception) { - Logging.error("Exception in suspendifyWithCompletion", e) - } + thread(priority = priority) { + try { + runBlocking { block() } + onComplete?.invoke() + } catch (e: Exception) { + Logging.error("Exception on thread", e) } } } /** - * Modern utility for executing suspending code with error handling. - * Uses OneSignal's centralized thread management with comprehensive error handling. - * - * @param useIO Whether to use IO scope (true) or Default scope (false) - * @param block The suspending code to execute - * @param onError Optional error handler - * @param onComplete Optional completion handler + * Allows a non suspending function to create a scope that can + * call suspending functions. This is a nonblocking call, which + * means the scope will run on a background thread. This will + * return immediately!!! */ -fun suspendifyWithErrorHandling( - useIO: Boolean = true, +fun suspendifyOnThread( + name: String, + priority: Int = -1, block: suspend () -> Unit, - onError: ((Exception) -> Unit)? = null, - onComplete: (() -> Unit)? = null, ) { - if (useIO) { - OneSignalDispatchers.launchOnIO { - try { - block() - onComplete?.invoke() - } catch (e: Exception) { - Logging.error("Exception in suspendifyWithErrorHandling", e) - onError?.invoke(e) - } - } - } else { - OneSignalDispatchers.launchOnDefault { - try { + thread(name = name, priority = priority) { + try { + runBlocking { block() - onComplete?.invoke() - } catch (e: Exception) { - Logging.error("Exception in suspendifyWithErrorHandling", e) - onError?.invoke(e) } + } catch (e: Exception) { + Logging.error("Exception on thread '$name'", e) } } } - -/** - * Launch suspending code on IO dispatcher and return a Job for waiting. - * This is useful when you need to wait for the background work to complete. - * - * @param block The suspending code to execute - * @return Job that can be used to wait for completion with .join() - */ -fun launchOnIO(block: suspend () -> Unit): Job { - return OneSignalDispatchers.launchOnIO(block) -} - -/** - * Launch suspending code on Default dispatcher and return a Job for waiting. - * This is useful when you need to wait for the background work to complete. - * - * @param block The suspending code to execute - * @return Job that can be used to wait for completion with .join() - */ -fun launchOnDefault(block: suspend () -> Unit): kotlinx.coroutines.Job { - return OneSignalDispatchers.launchOnDefault(block) -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt index 6f1d3bdfca..212d892546 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt @@ -1,74 +1,47 @@ package com.onesignal.core.activities +import android.app.Activity import android.content.Intent +import android.content.pm.PackageManager import android.os.Build import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.viewModels +import android.os.Handler import androidx.core.app.ActivityCompat -import androidx.lifecycle.lifecycleScope +import com.onesignal.OneSignal import com.onesignal.core.R -import com.onesignal.core.internal.permissions.AlertDialogPrepromptForAndroidSettings -import com.onesignal.core.internal.permissions.PermissionsViewModel -import com.onesignal.core.internal.permissions.PermissionsViewModel.Companion.INTENT_EXTRA_ANDROID_PERMISSION_STRING -import com.onesignal.core.internal.permissions.PermissionsViewModel.Companion.INTENT_EXTRA_CALLBACK_CLASS -import com.onesignal.core.internal.permissions.PermissionsViewModel.Companion.INTENT_EXTRA_PERMISSION_TYPE -import com.onesignal.core.internal.permissions.PermissionsViewModel.Companion.ONESIGNAL_PERMISSION_REQUEST_CODE -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -/** - * Activity that handles runtime permission requests for OneSignal. - * Uses ViewModel for business logic and state management that survives configuration changes. - */ -class PermissionsActivity : ComponentActivity() { - private val viewModel: PermissionsViewModel by viewModels() +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 + +class PermissionsActivity : Activity() { + private var requestPermissionService: RequestPermissionService? = null + private var preferenceService: IPreferencesService? = null + private var permissionRequestType: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (!OneSignal.initWithContext(this)) { + finishActivity() + return + } + if (intent.extras == null) { // This should never happen, but extras is null in rare crash reports finishActivity() return } - // Observe the shouldFinish state to know when to close the activity - lifecycleScope.launch { - viewModel.shouldFinish.collectLatest { shouldFinish -> - if (shouldFinish) { - finishActivity() - } - } - } + requestPermissionService = OneSignal.getService() + preferenceService = OneSignal.getService() - // Only handle bundle params on first creation, not on config changes - // ViewModel retains state across config changes, so permission state survives rotation - if (savedInstanceState == null) { - lifecycleScope.launch { - handleBundleParams(intent.extras) - } - } + handleBundleParams(intent.extras) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - lifecycleScope.launch { - handleBundleParams(intent.extras) - } - } - - override fun onPause() { - super.onPause() - // Reset waiting state when activity loses focus - // This ensures permission dialog can be shown again if activity was interrupted - viewModel.resetWaitingState() - } - - override fun onDestroy() { - super.onDestroy() - // Dismiss any active dialogs to prevent WindowLeaked errors - AlertDialogPrepromptForAndroidSettings.dismissCurrentDialog() + handleBundleParams(intent.extras) } private fun finishActivity() { @@ -76,7 +49,7 @@ class PermissionsActivity : ComponentActivity() { overridePendingTransition(R.anim.onesignal_fade_in, R.anim.onesignal_fade_out) } - private suspend fun handleBundleParams(extras: Bundle?) { + private fun handleBundleParams(extras: Bundle?) { // https://github.com/OneSignal/OneSignal-Android-SDK/issues/30 // Activity maybe invoked directly through automated testing, omit prompting on old Android versions. if (Build.VERSION.SDK_INT < 23) { @@ -85,17 +58,9 @@ class PermissionsActivity : ComponentActivity() { } reregisterCallbackHandlers(extras) - - val permissionType = extras!!.getString(INTENT_EXTRA_PERMISSION_TYPE) + permissionRequestType = extras!!.getString(INTENT_EXTRA_PERMISSION_TYPE) val androidPermissionString = extras.getString(INTENT_EXTRA_ANDROID_PERMISSION_STRING) - // Initialize OneSignal and ViewModel (handles initialization in one place) - if (!viewModel.initialize(this, permissionType, androidPermissionString)) { - finishActivity() - return - } - - // Request permission - this is Activity-layer logic requestPermission(androidPermissionString!!) } @@ -112,22 +77,14 @@ class PermissionsActivity : ComponentActivity() { } } - /** - * Request permission from the Activity (not ViewModel). - * This is UI-layer logic that should not be in the ViewModel. - */ private fun requestPermission(androidPermissionString: String) { - // Check if we should request (ViewModel tracks state) - if (viewModel.shouldRequestPermission()) { - // Store the rationale state before requesting - viewModel.recordRationaleState( + if (!requestPermissionService!!.waiting) { + requestPermissionService!!.waiting = true + requestPermissionService!!.shouldShowRequestPermissionRationaleBeforeRequest = ActivityCompat.shouldShowRequestPermissionRationale( - this, + this@PermissionsActivity, androidPermissionString, - ), - ) - - // Actually request the permission (Activity responsibility) + ) ActivityCompat.requestPermissions( this, arrayOf(androidPermissionString), @@ -142,7 +99,7 @@ class PermissionsActivity : ComponentActivity() { permissions: Array, grantResults: IntArray, ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) + requestPermissionService!!.waiting = false // TODO improve this method // TODO after we remove IAM from being an activity window we may be able to remove this handler @@ -151,17 +108,80 @@ class PermissionsActivity : ComponentActivity() { // is being called before the prompt activity dismisses, so it's attaching the IAM to PermissionActivity // We need to wait for other activity to show if (requestCode == ONESIGNAL_PERMISSION_REQUEST_CODE) { - // Check shouldShowRequestPermissionRationale AFTER the user responded - val shouldShowRationaleAfter = - if (permissions.isNotEmpty()) { - ActivityCompat.shouldShowRequestPermissionRationale(this, permissions[0]) + Handler().postDelayed({ + val callback = + requestPermissionService!!.getCallback(permissionRequestType!!) + ?: throw RuntimeException("Missing handler for permissionRequestType: $permissionRequestType") + + // It is possible that the permissions request interaction with the user is interrupted. In this case + // we will receive empty permissions which should be treated as a cancellation and will not prompt + // for the permission setting + val defaultFallbackSetting = false + if (permissions.isEmpty()) { + callback.onReject(defaultFallbackSetting) } else { - false + val permission = permissions[0] + val granted = + grantResults.size > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED + + if (granted) { + callback.onAccept() + preferenceService!!.saveBool( + PreferenceStores.ONESIGNAL, + "${PreferenceOneSignalKeys.PREFS_OS_USER_RESOLVED_PERMISSION_PREFIX}$permission", + true, + ) + } else { + callback.onReject(shouldShowSettings(permission)) + } } + }, DELAY_TIME_CALLBACK_CALL.toLong()) + } + + finishActivity() + } + + private fun shouldShowSettings(permission: String): Boolean { + if (!requestPermissionService!!.fallbackToSettings) { + return false + } - // Let ViewModel handle the business logic - viewModel.onRequestPermissionsResult(permissions, grantResults, shouldShowRationaleAfter) - // Activity will finish when ViewModel sets shouldFinish state + // 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 (requestPermissionService!!.shouldShowRequestPermissionRationaleBeforeRequest) { + if (!ActivityCompat.shouldShowRequestPermissionRationale( + this@PermissionsActivity, + permission, + ) + ) { + preferenceService!!.saveBool( + PreferenceStores.ONESIGNAL, + "${PreferenceOneSignalKeys.PREFS_OS_USER_RESOLVED_PERMISSION_PREFIX}$permission", + true, + ) + return false + } } + + return preferenceService!!.getBool( + PreferenceStores.ONESIGNAL, + "${PreferenceOneSignalKeys.PREFS_OS_USER_RESOLVED_PERMISSION_PREFIX}$permission", + false, + )!! + } + + companion object { + // TODO this will be removed once the handled is deleted + // Default animation duration in milliseconds + const val DELAY_TIME_CALLBACK_CALL = 500 + const val ONESIGNAL_PERMISSION_REQUEST_CODE = 2 + + const val INTENT_EXTRA_PERMISSION_TYPE = "INTENT_EXTRA_PERMISSION_TYPE" + const val INTENT_EXTRA_ANDROID_PERMISSION_STRING = + "INTENT_EXTRA_ANDROID_PERMISSION_STRING" + const val INTENT_EXTRA_CALLBACK_CLASS = "INTENT_EXTRA_CALLBACK_CLASS" } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt index 5e3664e5f7..87d7eae6b0 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt @@ -4,7 +4,7 @@ import com.onesignal.common.exceptions.BackendException import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.common.modeling.ModelChangedArgs -import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.internal.backend.IParamsBackendService import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore @@ -60,7 +60,7 @@ internal class ConfigModelStoreListener( return } - suspendifyOnIO { + suspendifyOnThread { Logging.debug("ConfigModelListener: fetching parameters for appId: $appId") var androidParamsRetries = 0 @@ -108,7 +108,7 @@ internal class ConfigModelStoreListener( } catch (ex: BackendException) { if (ex.statusCode == HttpURLConnection.HTTP_FORBIDDEN) { Logging.fatal("403 error getting OneSignal params, omitting further retries!") - return@suspendifyOnIO + return@suspendifyOnThread } else { var sleepTime = MIN_WAIT_BETWEEN_RETRIES + androidParamsRetries * INCREASE_BETWEEN_RETRIES if (sleepTime > MAX_WAIT_BETWEEN_RETRIES) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt index 825637f31c..00748d428e 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt @@ -5,7 +5,6 @@ import android.os.Build import com.onesignal.common.JSONUtils import com.onesignal.common.OneSignalUtils import com.onesignal.common.OneSignalWrapper -import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.device.IInstallIdService import com.onesignal.core.internal.http.HttpResponse @@ -15,8 +14,12 @@ import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.core.internal.time.ITime import com.onesignal.debug.internal.logging.Logging +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import org.json.JSONObject import java.net.ConnectException @@ -97,6 +100,7 @@ internal class HttpClient( } } + @OptIn(DelicateCoroutinesApi::class) private suspend fun makeRequestIODispatcher( url: String, method: String?, @@ -107,7 +111,7 @@ internal class HttpClient( var retVal: HttpResponse? = null val job = - OneSignalDispatchers.launchOnIO { + GlobalScope.launch(Dispatchers.IO) { var httpResponse = -1 var con: HttpURLConnection? = null 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 10d3b4dfa5..1861261506 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 @@ -1,7 +1,7 @@ package com.onesignal.core.internal.operations.impl +import com.onesignal.common.threading.OSPrimaryCoroutineScope import com.onesignal.common.threading.WaiterWithValue -import com.onesignal.common.threading.suspendifyOnIO import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.GroupComparisonType @@ -14,7 +14,10 @@ import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.user.internal.operations.impl.states.NewRecordsState import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.withTimeoutOrNull import java.util.UUID import kotlin.math.max @@ -48,6 +51,7 @@ internal class OperationRepo( private val waiter = WaiterWithValue() private val retryWaiter = WaiterWithValue() private var paused = false + private var coroutineScope = CoroutineScope(newSingleThreadContext(name = "OpRepo")) private val initialized = CompletableDeferred() override suspend fun awaitInitialized() { @@ -92,7 +96,7 @@ internal class OperationRepo( override fun start() { paused = false - suspendifyOnIO { + coroutineScope.launch { // load saved operations first then start processing the queue to ensure correct operation order loadSavedOperations() processQueueForever() @@ -113,8 +117,7 @@ internal class OperationRepo( Logging.log(LogLevel.DEBUG, "OperationRepo.enqueue(operation: $operation, flush: $flush)") operation.id = UUID.randomUUID().toString() - // Use suspendifyOnIO to ensure non-blocking behavior for main thread - suspendifyOnIO { + OSPrimaryCoroutineScope.execute { internalEnqueue(OperationQueueItem(operation, bucket = enqueueIntoBucket), flush, true) } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/AlertDialogPrepromptForAndroidSettings.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/AlertDialogPrepromptForAndroidSettings.kt index ecb00d0ee5..5fd7445c0c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/AlertDialogPrepromptForAndroidSettings.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/AlertDialogPrepromptForAndroidSettings.kt @@ -38,38 +38,17 @@ import com.onesignal.debug.internal.logging.Logging * A singleton helper which will display the fallback-to-settings alert dialog. */ object AlertDialogPrepromptForAndroidSettings { - private var currentDialog: AlertDialog? = null - interface Callback { fun onAccept() fun onDecline() } - /** - * Dismiss the current dialog if it exists. - * This should be called when the Activity is destroyed to prevent WindowLeaked errors. - */ - fun dismissCurrentDialog() { - currentDialog?.dismiss() - currentDialog = null - } - - fun show( - activity: Activity, - titlePrefix: String, - previouslyDeniedPostfix: String, - callback: Callback, - ) { - show(activity, titlePrefix, previouslyDeniedPostfix, callback, null) - } - fun show( activity: Activity, titlePrefix: String, previouslyDeniedPostfix: String, callback: Callback, - dismissCallback: (() -> Unit)?, ) { val titleTemplate = activity.getString(R.string.permission_not_available_title) val title = titleTemplate.format(titlePrefix) @@ -79,26 +58,19 @@ object AlertDialogPrepromptForAndroidSettings { // Try displaying the dialog while handling cases where execution is not possible. try { - val dialog = - AlertDialog.Builder(activity) - .setTitle(title) - .setMessage(message) - .setPositiveButton(R.string.permission_not_available_open_settings_option) { _, _ -> - callback.onAccept() - } - .setNegativeButton(android.R.string.no) { _, _ -> - callback.onDecline() - } - .setOnCancelListener { - callback.onDecline() - } - .setOnDismissListener { - currentDialog = null - dismissCallback?.invoke() - } - .show() - - currentDialog = dialog + AlertDialog.Builder(activity) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.permission_not_available_open_settings_option) { dialog, which -> + callback.onAccept() + } + .setNegativeButton(android.R.string.no) { dialog, which -> + callback.onDecline() + } + .setOnCancelListener { + callback.onDecline() + } + .show() } catch (ex: BadTokenException) { // If Android is unable to display the dialog, trigger the onDecline callback to maintain // consistency with the behavior when the dialog is canceled or dismissed without a response. 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 deleted file mode 100644 index 3612f81fa0..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt +++ /dev/null @@ -1,224 +0,0 @@ -package com.onesignal.core.internal.permissions - -import android.app.Activity -import android.content.pm.PackageManager -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.onesignal.OneSignal -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 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 - */ -class PermissionsViewModel : ViewModel() { - // Lazy initialization to ensure OneSignal is ready before accessing services - private val requestPermissionService: RequestPermissionService by lazy { OneSignal.getService() } - private val preferenceService: IPreferencesService by lazy { OneSignal.getService() } - - private val _shouldFinish = MutableStateFlow(false) - val shouldFinish: StateFlow = _shouldFinish.asStateFlow() - - private val _waiting = MutableStateFlow(false) - val waiting: StateFlow = _waiting.asStateFlow() - - var permissionRequestType: String? = null - private set - - 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) - */ - suspend fun initialize( - activity: Activity, - permissionType: String?, - androidPermission: String?, - ): Boolean { - // First ensure OneSignal is initialized - if (!OneSignal.initWithContext(activity)) { - _shouldFinish.value = true - return false - } - - // Then validate intent parameters - if (permissionType == null || androidPermission == null) { - _shouldFinish.value = true - return false - } - - permissionRequestType = permissionType - androidPermissionString = androidPermission - return true - } - - /** - * Check if we should request permission (prevents duplicate requests). - * Activity should call this before requesting permission. - */ - fun shouldRequestPermission(): Boolean { - if (_waiting.value) { - return false - } - _waiting.value = true - 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. - */ - fun resetWaitingState() { - _waiting.value = false - } - - /** - * Record the rationale state before the permission request. - * Activity calls this with the result of shouldShowRequestPermissionRationale(). - */ - fun recordRationaleState(shouldShowRationale: Boolean) { - 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 - */ - fun onRequestPermissionsResult( - permissions: Array, - grantResults: IntArray, - shouldShowRationaleAfter: Boolean = false, - ) { - _waiting.value = false - - // Use viewModelScope with delay for smooth transition - 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) - } - } - - // Execute the callback - executeCallback(granted, showSettings) - - // Signal the activity to finish - _shouldFinish.value = true - } - } - - private fun executeCallback( - granted: Boolean, - showSettings: Boolean, - ) { - val callback = - requestPermissionService.getCallback(permissionRequestType!!) - ?: throw RuntimeException("Missing handler for permissionRequestType: $permissionRequestType") - - if (granted) { - callback.onAccept() - } else { - callback.onReject(showSettings) - } - } - - /** - * 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 - } - - // 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 (requestPermissionService.shouldShowRequestPermissionRationaleBeforeRequest) { - if (!shouldShowRationaleAfter) { - // The rationale changed from true -> false, meaning permanent denial - preferenceService.saveBool( - PreferenceStores.ONESIGNAL, - "${PreferenceOneSignalKeys.PREFS_OS_USER_RESOLVED_PERMISSION_PREFIX}$permission", - true, - ) - return false - } - } - - return preferenceService.getBool( - PreferenceStores.ONESIGNAL, - "${PreferenceOneSignalKeys.PREFS_OS_USER_RESOLVED_PERMISSION_PREFIX}$permission", - false, - ) ?: false - } - - override fun onCleared() { - super.onCleared() - // Clean up any resources if needed - } - - companion object { - // TODO this will be removed once the handler is deleted - // Default animation duration in milliseconds - const val DELAY_TIME_CALLBACK_CALL = 500 - const val ONESIGNAL_PERMISSION_REQUEST_CODE = 2 - - const val INTENT_EXTRA_PERMISSION_TYPE = "INTENT_EXTRA_PERMISSION_TYPE" - const val INTENT_EXTRA_ANDROID_PERMISSION_STRING = - "INTENT_EXTRA_ANDROID_PERMISSION_STRING" - const val INTENT_EXTRA_CALLBACK_CLASS = "INTENT_EXTRA_CALLBACK_CLASS" - } -} 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..6a774f8ef8 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 @@ -7,7 +7,6 @@ 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 internal class RequestPermissionService( private val _application: IApplicationService, @@ -53,9 +52,9 @@ internal class RequestPermissionService( } 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) + intent.putExtra(PermissionsActivity.INTENT_EXTRA_PERMISSION_TYPE, permissionRequestType) + .putExtra(PermissionsActivity.INTENT_EXTRA_ANDROID_PERMISSION_STRING, androidPermissionString) + .putExtra(PermissionsActivity.INTENT_EXTRA_CALLBACK_CLASS, callbackClass.name) activity.startActivity(intent) activity.overridePendingTransition( R.anim.onesignal_fade_in, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/PreferencesExtensionV4.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/PreferencesExtensionV4.kt deleted file mode 100644 index 0d9526cdc8..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/PreferencesExtensionV4.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.onesignal.core.internal.preferences - -/** - * Returns the cached app ID from v4 of the SDK, if available. - * This is to maintain compatibility with apps that have not updated to the latest app ID. - */ -fun IPreferencesService.getLegacyAppId() = - getString( - PreferenceStores.ONESIGNAL, - PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID, - ) - -/** - * Returns the cached legacy player ID from v4 of the SDK, if available. - * Used to determine if migration from v4 to v5 is needed. - */ -fun IPreferencesService.getLegacyPlayerId() = - getString( - PreferenceStores.ONESIGNAL, - PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID, - ) - -/** - * Returns the cached Legacy User Sync Values from v4 of the SDK, if available. - * This maintains compatibility with apps upgrading from v4 to v5. - */ -fun IPreferencesService.getLegacyUserSyncValues() = - getString( - PreferenceStores.ONESIGNAL, - PreferenceOneSignalKeys.PREFS_LEGACY_USER_SYNCVALUES, - ) - -/** - * Clears the legacy player ID from v4 of the SDK. - * Called after successfully migrating user data to v5 format. - */ -fun IPreferencesService.clearLegacyPlayerId() = - saveString( - PreferenceStores.ONESIGNAL, - PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID, - null, - ) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/impl/PreferencesService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/impl/PreferencesService.kt index 725f56a7bc..e0d4f34f19 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/impl/PreferencesService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/impl/PreferencesService.kt @@ -2,7 +2,6 @@ package com.onesignal.core.internal.preferences.impl import android.content.Context import android.content.SharedPreferences -import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.common.threading.Waiter import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.preferences.IPreferencesService @@ -11,6 +10,10 @@ import com.onesignal.core.internal.startup.IStartableService import com.onesignal.core.internal.time.ITime import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async import kotlinx.coroutines.delay internal class PreferencesService( @@ -22,11 +25,13 @@ internal class PreferencesService( PreferenceStores.ONESIGNAL to mutableMapOf(), PreferenceStores.PLAYER_PURCHASES to mutableMapOf(), ) + private var queueJob: Deferred? = null + private val waiter = Waiter() override fun start() { // fire up an async job that will run "forever" so we don't hold up the other startable services. - doWorkAsync() + queueJob = doWorkAsync() } override fun getString( @@ -170,7 +175,7 @@ internal class PreferencesService( } private fun doWorkAsync() = - OneSignalDispatchers.launchOnIO { + GlobalScope.async(Dispatchers.IO) { var lastSyncTime = _time.currentTimeMillis while (true) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/startup/StartupService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/startup/StartupService.kt index 9d1c112d64..e183c0f59c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/startup/StartupService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/startup/StartupService.kt @@ -1,7 +1,6 @@ package com.onesignal.core.internal.startup import com.onesignal.common.services.ServiceProvider -import com.onesignal.common.threading.OneSignalDispatchers internal class StartupService( private val services: ServiceProvider, @@ -10,10 +9,10 @@ internal class StartupService( services.getAllServices().forEach { it.bootstrap() } } - // schedule to start all startable services using OneSignal dispatcher + // schedule to start all startable services in a separate thread fun scheduleStart() { - OneSignalDispatchers.launchOnDefault { + Thread { services.getAllServices().forEach { it.start() } - } + }.start() } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/services/SyncJobService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/services/SyncJobService.kt index cc664818ac..ef98e5388c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/services/SyncJobService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/services/SyncJobService.kt @@ -29,20 +29,19 @@ package com.onesignal.core.services import android.app.job.JobParameters import android.app.job.JobService import com.onesignal.OneSignal -import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.internal.background.IBackgroundManager import com.onesignal.debug.internal.logging.Logging class SyncJobService : JobService() { override fun onStartJob(jobParameters: JobParameters): Boolean { - suspendifyOnIO { - // init OneSignal in background - if (!OneSignal.initWithContext(this)) { - jobFinished(jobParameters, false) - return@suspendifyOnIO - } + if (!OneSignal.initWithContext(this)) { + return false + } + + var backgroundService = OneSignal.getService() - val backgroundService = OneSignal.getService() + suspendifyOnThread { backgroundService.runBackgroundServices() Logging.debug("LollipopSyncRunnable:JobFinished needsJobReschedule: " + backgroundService.needsJobReschedule) @@ -50,6 +49,7 @@ class SyncJobService : JobService() { // Reschedule if needed val reschedule = backgroundService.needsJobReschedule backgroundService.needsJobReschedule = false + jobFinished(jobParameters, reschedule) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/InitState.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/InitState.kt deleted file mode 100644 index 4de391be86..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/InitState.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.onesignal.internal - -/** - * Represents the current initialization state of the OneSignal SDK. - * - * This enum is used to track the lifecycle of SDK initialization, ensuring that operations like `login`, - * `logout`, or accessing services are only allowed when the SDK is fully initialized. - */ -internal enum class InitState { - /** - * SDK initialization has not yet started. - * Calling SDK-dependent methods in this state will throw an exception. - */ - NOT_STARTED, - - /** - * SDK initialization is currently in progress. - * Calls that require initialization will block (via a latch) until this completes. - */ - IN_PROGRESS, - - /** - * SDK initialization completed successfully. - * All SDK-dependent operations can proceed safely. - */ - SUCCESS, - - /** - * SDK initialization has failed due to an unrecoverable error (e.g., missing app ID). - * All dependent operations should fail fast or throw until re-initialized. - */ - FAILED, - - ; - - fun isSDKAccessible(): Boolean { - return this == IN_PROGRESS || this == SUCCESS - } -} 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 99024c3da4..eaa00225e0 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 @@ -1,17 +1,20 @@ package com.onesignal.internal import android.content.Context +import android.os.Build import com.onesignal.IOneSignal import com.onesignal.common.AndroidUtils import com.onesignal.common.DeviceUtils +import com.onesignal.common.IDManager import com.onesignal.common.OneSignalUtils +import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.common.modules.IModule +import com.onesignal.common.safeInt +import com.onesignal.common.safeString import com.onesignal.common.services.IServiceProvider import com.onesignal.common.services.ServiceBuilder import com.onesignal.common.services.ServiceProvider -import com.onesignal.common.threading.CompletionAwaiter -import com.onesignal.common.threading.OneSignalDispatchers -import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.CoreModule import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.application.impl.ApplicationService @@ -19,7 +22,9 @@ import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.IOperationRepo import com.onesignal.core.internal.preferences.IPreferencesService +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStoreFix +import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.core.internal.startup.StartupService import com.onesignal.debug.IDebugManager import com.onesignal.debug.LogLevel @@ -30,502 +35,473 @@ import com.onesignal.location.ILocationManager import com.onesignal.notifications.INotificationsManager import com.onesignal.session.ISessionManager import com.onesignal.session.SessionModule +import com.onesignal.session.internal.session.SessionModel +import com.onesignal.session.internal.session.SessionModelStore import com.onesignal.user.IUserManager import com.onesignal.user.UserModule -import com.onesignal.user.internal.LoginHelper -import com.onesignal.user.internal.LogoutHelper -import com.onesignal.user.internal.UserSwitcher +import com.onesignal.user.internal.backend.IdentityConstants +import com.onesignal.user.internal.identity.IdentityModel import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.operations.LoginUserFromSubscriptionOperation +import com.onesignal.user.internal.operations.LoginUserOperation +import com.onesignal.user.internal.properties.PropertiesModel import com.onesignal.user.internal.properties.PropertiesModelStore -import com.onesignal.user.internal.resolveAppId +import com.onesignal.user.internal.subscriptions.SubscriptionModel import com.onesignal.user.internal.subscriptions.SubscriptionModelStore -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout - -private const val MAX_TIMEOUT_TO_INIT = 30_000L // 30 seconds - -internal class OneSignalImp( - private val ioDispatcher: CoroutineDispatcher = OneSignalDispatchers.IO, -) : IOneSignal, IServiceProvider { - @Volatile - private var initAwaiter = CompletionAwaiter("OneSignalImp") - - @Volatile - private var initState: InitState = InitState.NOT_STARTED +import com.onesignal.user.internal.subscriptions.SubscriptionStatus +import com.onesignal.user.internal.subscriptions.SubscriptionType +import org.json.JSONObject +internal class OneSignalImp : IOneSignal, IServiceProvider { override val sdkVersion: String = OneSignalUtils.sdkVersion - - override val isInitialized: Boolean - get() = initState == InitState.SUCCESS + override var isInitialized: Boolean = false override var consentRequired: Boolean - get() = - if (isInitialized) { - blockingGet { configModel.consentRequired ?: (_consentRequired == true) } - } else { - _consentRequired == true - } + get() = configModel?.consentRequired ?: (_consentRequired == true) set(value) { _consentRequired = value - if (isInitialized) { - configModel.consentRequired = value - } + configModel?.consentRequired = value } override var consentGiven: Boolean - get() = - if (isInitialized) { - blockingGet { configModel.consentGiven ?: (_consentGiven == true) } - } else { - _consentGiven == true - } + get() = configModel?.consentGiven ?: (_consentGiven == true) set(value) { val oldValue = _consentGiven _consentGiven = value - if (isInitialized) { - configModel.consentGiven = value - if (oldValue != value && value) { - operationRepo.forceExecuteOperations() - } + configModel?.consentGiven = value + if (oldValue != value && value) { + operationRepo?.forceExecuteOperations() } } override var disableGMSMissingPrompt: Boolean - get() = - if (isInitialized) { - blockingGet { configModel.disableGMSMissingPrompt ?: (_disableGMSMissingPrompt == true) } - } else { - _disableGMSMissingPrompt == true - } + get() = configModel?.disableGMSMissingPrompt ?: (_disableGMSMissingPrompt == true) set(value) { _disableGMSMissingPrompt = value - if (isInitialized) { - configModel.disableGMSMissingPrompt = value - } + configModel?.disableGMSMissingPrompt = value } // we hardcode the DebugManager implementation so it can be used prior to calling `initWithContext` override val debug: IDebugManager = DebugManager() - - override val session: ISessionManager - get() = - waitAndReturn { services.getService() } - - override val notifications: INotificationsManager - get() = - waitAndReturn { services.getService() } - - override val location: ILocationManager - get() = - waitAndReturn { services.getService() } - - override val inAppMessages: IInAppMessagesManager - get() = - waitAndReturn { services.getService() } - - override val user: IUserManager - get() = - waitAndReturn { services.getService() } + override val session: ISessionManager get() = + if (isInitialized) { + services.getService() + } else { + throw Exception( + "Must call 'initWithContext' before use", + ) + } + override val notifications: INotificationsManager get() = + if (isInitialized) { + services.getService() + } else { + throw Exception( + "Must call 'initWithContext' before use", + ) + } + override val location: ILocationManager get() = + if (isInitialized) { + services.getService() + } else { + throw Exception( + "Must call 'initWithContext' before use", + ) + } + override val inAppMessages: IInAppMessagesManager get() = + if (isInitialized) { + services.getService() + } else { + throw Exception( + "Must call 'initWithContext' before use", + ) + } + override val user: IUserManager get() = + if (isInitialized) { + services.getService() + } else { + throw Exception( + "Must call 'initWithContext' before use", + ) + } // Services required by this class // WARNING: OperationRepo depends on OperationModelStore which in-turn depends // on ApplicationService.appContext being non-null. - private val operationRepo: IOperationRepo by lazy { services.getService() } - private val identityModelStore: IdentityModelStore by lazy { services.getService() } - private val propertiesModelStore: PropertiesModelStore by lazy { services.getService() } - private val subscriptionModelStore: SubscriptionModelStore by lazy { services.getService() } - private val preferencesService: IPreferencesService by lazy { services.getService() } - private val listOfModules = - listOf( - "com.onesignal.notifications.NotificationsModule", - "com.onesignal.inAppMessages.InAppMessagesModule", - "com.onesignal.location.LocationModule", - ) - private val services: ServiceProvider = - ServiceBuilder().apply { - val modules = mutableListOf() - modules.add(CoreModule()) - modules.add(SessionModule()) - modules.add(UserModule()) - for (moduleClassName in listOfModules) { - try { - val moduleClass = Class.forName(moduleClassName) - val moduleInstance = moduleClass.newInstance() as IModule - modules.add(moduleInstance) - } catch (e: ClassNotFoundException) { - e.printStackTrace() - } - } - for (module in modules) { - module.register(this) - } - }.build() - - // get the current config model, if there is one - private val configModel: ConfigModel by lazy { services.getService().model } + private var operationRepo: IOperationRepo? = null + private val identityModelStore: IdentityModelStore + get() = services.getService() + private val propertiesModelStore: PropertiesModelStore + get() = services.getService() + private val subscriptionModelStore: SubscriptionModelStore + get() = services.getService() + private val preferencesService: IPreferencesService + get() = services.getService() + + // Other State + private val services: ServiceProvider + private var configModel: ConfigModel? = null + private var sessionModel: SessionModel? = null private var _consentRequired: Boolean? = null private var _consentGiven: Boolean? = null private var _disableGMSMissingPrompt: Boolean? = null private val initLock: Any = Any() - private val loginLogoutLock: Any = Any() - private val userSwitcher by lazy { - val appContext = services.getService().appContext - UserSwitcher( - identityModelStore = identityModelStore, - propertiesModelStore = propertiesModelStore, - subscriptionModelStore = subscriptionModelStore, - configModel = configModel, - carrierName = DeviceUtils.getCarrierName(appContext), - deviceOS = android.os.Build.VERSION.RELEASE, - appContextProvider = { appContext }, - preferencesService = preferencesService, - operationRepo = operationRepo, - services = services, - ) - } + private val loginLock: Any = Any() - private val loginHelper by lazy { - LoginHelper( - identityModelStore = identityModelStore, - userSwitcher = userSwitcher, - operationRepo = operationRepo, - configModel = configModel, - lock = loginLogoutLock, - ) - } - - private val logoutHelper by lazy { - LogoutHelper( - identityModelStore = identityModelStore, - userSwitcher = userSwitcher, - operationRepo = operationRepo, - configModel = configModel, - lock = loginLogoutLock, + private val listOfModules = + listOf( + "com.onesignal.notifications.NotificationsModule", + "com.onesignal.inAppMessages.InAppMessagesModule", + "com.onesignal.location.LocationModule", ) - } - - private fun initEssentials(context: Context) { - PreferenceStoreFix.ensureNoObfuscatedPrefStore(context) - - // start the application service. This is called explicitly first because we want - // to make sure it has the context provided on input, for all other startable services - // to depend on if needed. - val applicationService = services.getService() - (applicationService as ApplicationService).start(context) - - // Give the logging singleton access to the application service to support visual logging. - Logging.applicationService = applicationService - } - private fun updateConfig() { - // if requires privacy consent was set prior to init, set it in the model now - if (_consentRequired != null) { - configModel.consentRequired = _consentRequired!! - } - - // if privacy consent was set prior to init, set it in the model now - if (_consentGiven != null) { - configModel.consentGiven = _consentGiven!! + init { + val serviceBuilder = ServiceBuilder() + + val modules = mutableListOf() + + modules.add(CoreModule()) + modules.add(SessionModule()) + modules.add(UserModule()) + for (moduleClassName in listOfModules) { + try { + val moduleClass = Class.forName(moduleClassName) + val moduleInstance = moduleClass.newInstance() as IModule + modules.add(moduleInstance) + } catch (e: ClassNotFoundException) { + e.printStackTrace() + } } - if (_disableGMSMissingPrompt != null) { - configModel.disableGMSMissingPrompt = _disableGMSMissingPrompt!! + for (module in modules) { + module.register(serviceBuilder) } - } - private fun bootstrapServices(): StartupService { - val startupService = StartupService(services) - // bootstrap all services - startupService.bootstrap() - return startupService + services = serviceBuilder.build() } override fun initWithContext( context: Context, - appId: String, + appId: String?, ): Boolean { - Logging.log(LogLevel.DEBUG, "Calling deprecated initWithContextSuspend(context: $context, appId: $appId)") + Logging.log(LogLevel.DEBUG, "initWithContext(context: $context, appId: $appId)") - // do not do this again if already initialized or init is in progress synchronized(initLock) { - if (initState.isSDKAccessible()) { - Logging.log(LogLevel.DEBUG, "initWithContext: SDK already initialized or in progress") + // do not do this again if already initialized + if (isInitialized) { + Logging.log(LogLevel.DEBUG, "initWithContext: SDK already initialized") return true } - initState = InitState.IN_PROGRESS - } + Logging.log(LogLevel.DEBUG, "initWithContext: SDK initializing") + + PreferenceStoreFix.ensureNoObfuscatedPrefStore(context) + + // start the application service. This is called explicitly first because we want + // to make sure it has the context provided on input, for all other startable services + // to depend on if needed. + val applicationService = services.getService() + (applicationService as ApplicationService).start(context) + + // Give the logging singleton access to the application service to support visual logging. + Logging.applicationService = applicationService + + // get the current config model, if there is one + configModel = services.getService().model + sessionModel = services.getService().model + operationRepo = services.getService() + + var forceCreateUser = false + + // initWithContext is called by our internal services/receivers/activities but they do not provide + // an appId (they don't know it). If the app has never called the external initWithContext + // prior to our services/receivers/activities we will blow up, as no appId has been established. + if (appId == null && !configModel!!.hasProperty(ConfigModel::appId.name)) { + val legacyAppId = getLegacyAppId() + if (legacyAppId == null) { + Logging.warn("initWithContext called without providing appId, and no appId has been established!") + return false + } else { + Logging.debug("initWithContext: using cached legacy appId $legacyAppId") + forceCreateUser = true + configModel!!.appId = legacyAppId + } + } - // init in background and return immediately to ensure non-blocking - suspendifyOnIO { - internalInit(context, appId) - } - initState = InitState.SUCCESS - return true - } + // if the app id was specified as input, update the config model with it + if (appId != null) { + if (!configModel!!.hasProperty(ConfigModel::appId.name) || configModel!!.appId != appId) { + forceCreateUser = true + } + configModel!!.appId = appId + } - /** - * Called from internal classes only. Remain suspend until initialization is fully completed. - */ - override suspend fun initWithContext(context: Context): Boolean { - Logging.log(LogLevel.DEBUG, "initWithContext(context: $context)") - return initWithContextSuspend(context, null) - } + // if requires privacy consent was set prior to init, set it in the model now + if (_consentRequired != null) { + configModel!!.consentRequired = _consentRequired!! + } - private fun internalInit( - context: Context, - appId: String?, - ): Boolean { - initEssentials(context) - - val startupService = bootstrapServices() - val result = resolveAppId(appId, configModel, preferencesService) - if (result.failed) { - Logging.warn("suspendInitInternal: no appId provided or found in legacy config.") - initState = InitState.FAILED - notifyInitComplete() - return false - } - configModel.appId = result.appId!! // safe because failed is false - val forceCreateUser = result.forceCreateUser - - updateConfig() - userSwitcher.initUser(forceCreateUser) - startupService.scheduleStart() - initState = InitState.SUCCESS - notifyInitComplete() - return true - } + // if privacy consent was set prior to init, set it in the model now + if (_consentGiven != null) { + configModel!!.consentGiven = _consentGiven!! + } - override fun login( - externalId: String, - jwtBearerToken: String?, - ) { - Logging.log(LogLevel.DEBUG, "Calling deprecated login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)") + if (_disableGMSMissingPrompt != null) { + configModel!!.disableGMSMissingPrompt = _disableGMSMissingPrompt!! + } - if (!initState.isSDKAccessible()) { - throw IllegalStateException("Must call 'initWithContext' before 'login'") - } + val startupService = StartupService(services) + + // bootstrap services + startupService.bootstrap() + + if (forceCreateUser || !identityModelStore!!.model.hasProperty(IdentityConstants.ONESIGNAL_ID)) { + val legacyPlayerId = + preferencesService!!.getString( + PreferenceStores.ONESIGNAL, + PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID, + ) + if (legacyPlayerId == null) { + Logging.debug("initWithContext: creating new device-scoped user") + createAndSwitchToNewUser() + operationRepo!!.enqueue( + LoginUserOperation( + configModel!!.appId, + identityModelStore!!.model.onesignalId, + identityModelStore!!.model.externalId, + ), + ) + } else { + Logging.debug("initWithContext: creating user linked to subscription $legacyPlayerId") + + // Converting a 4.x SDK to the 5.x SDK. We pull the legacy user sync values to create the subscription model, then enqueue + // a specialized `LoginUserFromSubscriptionOperation`, which will drive fetching/refreshing of the local user + // based on the subscription ID we do have. + val legacyUserSyncString = + preferencesService!!.getString( + PreferenceStores.ONESIGNAL, + PreferenceOneSignalKeys.PREFS_LEGACY_USER_SYNCVALUES, + ) + var suppressBackendOperation = false + + if (legacyUserSyncString != null) { + val legacyUserSyncJSON = JSONObject(legacyUserSyncString) + val notificationTypes = legacyUserSyncJSON.safeInt("notification_types") + + val pushSubscriptionModel = SubscriptionModel() + pushSubscriptionModel.id = legacyPlayerId + pushSubscriptionModel.type = SubscriptionType.PUSH + pushSubscriptionModel.optedIn = + notificationTypes != SubscriptionStatus.NO_PERMISSION.value && notificationTypes != SubscriptionStatus.UNSUBSCRIBE.value + pushSubscriptionModel.address = + legacyUserSyncJSON.safeString("identifier") ?: "" + if (notificationTypes != null) { + pushSubscriptionModel.status = SubscriptionStatus.fromInt(notificationTypes) ?: SubscriptionStatus.NO_PERMISSION + } else { + pushSubscriptionModel.status = SubscriptionStatus.SUBSCRIBED + } + + pushSubscriptionModel.sdk = OneSignalUtils.sdkVersion + pushSubscriptionModel.deviceOS = Build.VERSION.RELEASE + pushSubscriptionModel.carrier = DeviceUtils.getCarrierName( + services.getService().appContext, + ) ?: "" + pushSubscriptionModel.appVersion = AndroidUtils.getAppVersion( + services.getService().appContext, + ) ?: "" + + configModel!!.pushSubscriptionId = legacyPlayerId + subscriptionModelStore!!.add( + pushSubscriptionModel, + ModelChangeTags.NO_PROPOGATE, + ) + suppressBackendOperation = true + } - waitForInit() - suspendifyOnIO { loginHelper.login(externalId, jwtBearerToken) } - } + createAndSwitchToNewUser(suppressBackendOperation = suppressBackendOperation) + + operationRepo!!.enqueue( + LoginUserFromSubscriptionOperation( + configModel!!.appId, + identityModelStore!!.model.onesignalId, + legacyPlayerId, + ), + ) + preferencesService!!.saveString( + PreferenceStores.ONESIGNAL, + PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID, + null, + ) + } + } else { + Logging.debug("initWithContext: using cached user ${identityModelStore!!.model.onesignalId}") + } - override fun logout() { - Logging.log(LogLevel.DEBUG, "Calling deprecated logout()") + // schedule service starts out of main thread + startupService.scheduleStart() - if (!initState.isSDKAccessible()) { - throw IllegalStateException("Must call 'initWithContext' before 'logout'") + isInitialized = true + return true } - - waitForInit() - suspendifyOnIO { logoutHelper.logout() } } - override fun hasService(c: Class): Boolean = services.hasService(c) - - override fun getService(c: Class): T = services.getService(c) - - override fun getServiceOrNull(c: Class): T? = services.getServiceOrNull(c) - - override fun getAllServices(c: Class): List = services.getAllServices(c) + override fun login( + externalId: String, + jwtBearerToken: String?, + ) { + Logging.log(LogLevel.DEBUG, "login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)") - private fun waitForInit() { - val completed = initAwaiter.await() - if (!completed) { - throw IllegalStateException("initWithContext was not called or timed out") + if (!isInitialized) { + throw Exception("Must call 'initWithContext' before 'login'") } - } - /** - * Notifies both blocking and suspend callers that initialization is complete - */ - private fun notifyInitComplete() { - initAwaiter.complete() - } + var currentIdentityExternalId: String? = null + var currentIdentityOneSignalId: String? = null + var newIdentityOneSignalId: String = "" - private suspend fun suspendUntilInit() { - when (initState) { - InitState.NOT_STARTED -> { - throw IllegalStateException("Must call 'initWithContext' before use") - } - InitState.IN_PROGRESS -> { - Logging.debug("Suspend waiting for init to complete...") - try { - withTimeout(MAX_TIMEOUT_TO_INIT) { - initAwaiter.awaitSuspend() - } - } catch (e: TimeoutCancellationException) { - throw IllegalStateException("initWithContext was timed out after $MAX_TIMEOUT_TO_INIT ms") - } - } - InitState.FAILED -> { - throw IllegalStateException("Initialization failed. Cannot proceed.") - } - else -> { - // SUCCESS - already initialized, no need to wait - } - } - } + // only allow one login/logout at a time + synchronized(loginLock) { + currentIdentityExternalId = identityModelStore!!.model.externalId + currentIdentityOneSignalId = identityModelStore!!.model.onesignalId - private suspend fun suspendAndReturn(getter: () -> T): T { - suspendUntilInit() - return getter() - } - - private fun waitAndReturn(getter: () -> T): T { - when (initState) { - InitState.NOT_STARTED -> { - throw IllegalStateException("Must call 'initWithContext' before use") - } - InitState.IN_PROGRESS -> { - Logging.debug("Waiting for init to complete...") - waitForInit() + if (currentIdentityExternalId == externalId) { + return } - InitState.FAILED -> { - throw IllegalStateException("Initialization failed. Cannot proceed.") - } - else -> { - // SUCCESS - waitForInit() - } - } - - return getter() - } - private fun blockingGet(getter: () -> T): T { - try { - if (AndroidUtils.isRunningOnMainThread()) { - Logging.warn("This is called on main thread. This is not recommended.") + // TODO: Set JWT Token for all future requests. + createAndSwitchToNewUser { identityModel, _ -> + identityModel.externalId = externalId } - } catch (e: RuntimeException) { - // In test environments, AndroidUtils.isRunningOnMainThread() may fail - // because Looper.getMainLooper() is not mocked. This is safe to ignore. - Logging.debug("Could not check main thread status (likely in test environment): ${e.message}") - } - return runBlocking(ioDispatcher) { - waitAndReturn(getter) - } - } - - // =============================== - // Suspend API Implementation - // =============================== - - override suspend fun getSession(): ISessionManager = - withContext(ioDispatcher) { - suspendAndReturn { services.getService() } - } - - override suspend fun getNotifications(): INotificationsManager = - withContext(ioDispatcher) { - suspendAndReturn { services.getService() } - } - override suspend fun getLocation(): ILocationManager = - withContext(ioDispatcher) { - suspendAndReturn { services.getService() } + newIdentityOneSignalId = identityModelStore!!.model.onesignalId } - override suspend fun getInAppMessages(): IInAppMessagesManager = - withContext(ioDispatcher) { - suspendAndReturn { services.getService() } - } - - override suspend fun getUser(): IUserManager = - withContext(ioDispatcher) { - suspendAndReturn { services.getService() } - } - - override suspend fun getConsentRequired(): Boolean = - withContext(ioDispatcher) { - configModel.consentRequired ?: (_consentRequired == true) + // on a background thread enqueue the login/fetch of the new user + suspendifyOnThread { + // We specify an "existingOneSignalId" here when the current user is anonymous to + // allow this login to attempt a "conversion" of the anonymous user. We also + // wait for the LoginUserOperation operation to execute, which can take a *very* long + // time if network conditions prevent the operation to succeed. This allows us to + // provide a callback to the caller when we can absolutely say the user is logged + // in, so they may take action on their own backend. + val result = + operationRepo!!.enqueueAndWait( + LoginUserOperation( + configModel!!.appId, + newIdentityOneSignalId, + externalId, + if (currentIdentityExternalId == null) currentIdentityOneSignalId else null, + ), + ) + + if (!result) { + Logging.log(LogLevel.ERROR, "Could not login user") + } } + } - override suspend fun setConsentRequired(required: Boolean) = - withContext(ioDispatcher) { - _consentRequired = required - configModel.consentRequired = required - } + override fun logout() { + Logging.log(LogLevel.DEBUG, "logout()") - override suspend fun getConsentGiven(): Boolean = - withContext(ioDispatcher) { - configModel.consentGiven ?: (_consentGiven == true) + if (!isInitialized) { + throw Exception("Must call 'initWithContext' before 'logout'") } - override suspend fun setConsentGiven(value: Boolean) = - withContext(ioDispatcher) { - val oldValue = _consentGiven - _consentGiven = value - configModel.consentGiven = value - if (oldValue != value && value) { - operationRepo.forceExecuteOperations() + // only allow one login/logout at a time + synchronized(loginLock) { + if (identityModelStore!!.model.externalId == null) { + return } - } - override suspend fun getDisableGMSMissingPrompt(): Boolean = - withContext(ioDispatcher) { - configModel.disableGMSMissingPrompt - } + createAndSwitchToNewUser() + operationRepo!!.enqueue( + LoginUserOperation( + configModel!!.appId, + identityModelStore!!.model.onesignalId, + identityModelStore!!.model.externalId, + ), + ) - override suspend fun setDisableGMSMissingPrompt(value: Boolean) = - withContext(ioDispatcher) { - _disableGMSMissingPrompt = value - configModel.disableGMSMissingPrompt = value + // TODO: remove JWT Token for all future requests. } + } - override suspend fun initWithContextSuspend( - context: Context, - appId: String?, - ): Boolean { - Logging.log(LogLevel.DEBUG, "initWithContext(context: $context, appId: $appId)") + /** + * Returns the cached app ID from v4 of the SDK, if available. + */ + private fun getLegacyAppId(): String? { + return preferencesService.getString( + PreferenceStores.ONESIGNAL, + PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID, + ) + } - // Use IO dispatcher for initialization to prevent ANRs and optimize for I/O operations - return withContext(ioDispatcher) { - // do not do this again if already initialized or init is in progress - synchronized(initLock) { - if (initState.isSDKAccessible()) { - Logging.log(LogLevel.DEBUG, "initWithContext: SDK already initialized or in progress") - return@withContext true - } + private fun createAndSwitchToNewUser( + suppressBackendOperation: Boolean = false, + modify: ( + (identityModel: IdentityModel, propertiesModel: PropertiesModel) -> Unit + )? = null, + ) { + Logging.debug("createAndSwitchToNewUser()") - initState = InitState.IN_PROGRESS - } + // create a new identity and properties model locally + val sdkId = IDManager.createLocalId() - val result = internalInit(context, appId) - // initState is already set correctly in internalInit, no need to overwrite it - result - } - } + val identityModel = IdentityModel() + identityModel.onesignalId = sdkId - override suspend fun loginSuspend( - externalId: String, - jwtBearerToken: String?, - ) = withContext(ioDispatcher) { - Logging.log(LogLevel.DEBUG, "login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)") + val propertiesModel = PropertiesModel() + propertiesModel.onesignalId = sdkId - suspendUntilInit() - if (!isInitialized) { - throw IllegalStateException("'initWithContext failed' before 'login'") + if (modify != null) { + modify(identityModel, propertiesModel) } - loginHelper.login(externalId, jwtBearerToken) + val subscriptions = mutableListOf() + + // Create the push subscription for this device under the new user, copying the current + // user's push subscription if one exists. We also copy the ID. If the ID is local there + // will already be a CreateSubscriptionOperation on the queue. If the ID is remote the subscription + // will be automatically transferred over to this new user being created. If there is no + // current push subscription we do a "normal" replace which will drive adding a CreateSubscriptionOperation + // to the queue. + val currentPushSubscription = subscriptionModelStore!!.list().firstOrNull { it.id == configModel!!.pushSubscriptionId } + val newPushSubscription = SubscriptionModel() + + newPushSubscription.id = currentPushSubscription?.id ?: IDManager.createLocalId() + newPushSubscription.type = SubscriptionType.PUSH + newPushSubscription.optedIn = currentPushSubscription?.optedIn ?: true + newPushSubscription.address = currentPushSubscription?.address ?: "" + newPushSubscription.status = currentPushSubscription?.status ?: SubscriptionStatus.NO_PERMISSION + newPushSubscription.sdk = OneSignalUtils.sdkVersion + newPushSubscription.deviceOS = Build.VERSION.RELEASE + newPushSubscription.carrier = DeviceUtils.getCarrierName(services.getService().appContext) ?: "" + newPushSubscription.appVersion = AndroidUtils.getAppVersion(services.getService().appContext) ?: "" + + // ensure we always know this devices push subscription ID + configModel!!.pushSubscriptionId = newPushSubscription.id + + subscriptions.add(newPushSubscription) + + // The next 4 lines makes this user the effective user locally. We clear the subscriptions + // first as a `NO_PROPOGATE` change because we don't want to drive deleting the cleared subscriptions + // on the backend. Once cleared we can then setup the new identity/properties model, and add + // the new user's subscriptions as a `NORMAL` change, which will drive changes to the backend. + subscriptionModelStore!!.clear(ModelChangeTags.NO_PROPOGATE) + identityModelStore!!.replace(identityModel) + propertiesModelStore!!.replace(propertiesModel) + + if (suppressBackendOperation) { + subscriptionModelStore!!.replaceAll(subscriptions, ModelChangeTags.NO_PROPOGATE) + } else { + subscriptionModelStore!!.replaceAll(subscriptions) + } } - override suspend fun logoutSuspend() = - withContext(ioDispatcher) { - Logging.log(LogLevel.DEBUG, "logoutSuspend()") + override fun hasService(c: Class): Boolean = services.hasService(c) - suspendUntilInit() + override fun getService(c: Class): T = services.getService(c) - if (!isInitialized) { - throw IllegalStateException("'initWithContext failed' before 'logout'") - } + override fun getServiceOrNull(c: Class): T? = services.getServiceOrNull(c) - logoutHelper.logout() - } + override fun getAllServices(c: Class): List = services.getAllServices(c) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/SessionManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/SessionManager.kt index 7c803cc167..081729903f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/SessionManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/SessionManager.kt @@ -1,6 +1,6 @@ package com.onesignal.session.internal -import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.session.ISessionManager @@ -12,7 +12,7 @@ internal open class SessionManager( override fun addOutcome(name: String) { Logging.log(LogLevel.DEBUG, "sendOutcome(name: $name)") - suspendifyOnIO { + suspendifyOnThread { _outcomeController.sendOutcomeEvent(name) } } @@ -20,7 +20,7 @@ internal open class SessionManager( override fun addUniqueOutcome(name: String) { Logging.log(LogLevel.DEBUG, "sendUniqueOutcome(name: $name)") - suspendifyOnIO { + suspendifyOnThread { _outcomeController.sendUniqueOutcomeEvent(name) } } @@ -31,7 +31,7 @@ internal open class SessionManager( ) { Logging.log(LogLevel.DEBUG, "sendOutcomeWithValue(name: $name, value: $value)") - suspendifyOnIO { + suspendifyOnThread { _outcomeController.sendOutcomeEventWithValue(name, value) } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/influence/impl/ChannelTracker.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/influence/impl/ChannelTracker.kt index 67ba06c124..52ef327de8 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/influence/impl/ChannelTracker.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/influence/impl/ChannelTracker.kt @@ -10,162 +10,162 @@ import org.json.JSONObject internal abstract class ChannelTracker internal constructor(protected var dataRepository: InfluenceDataRepository, private var timeProvider: ITime) : IChannelTracker { - override var influenceType: InfluenceType? = null - override var indirectIds: JSONArray? = null - override var directId: String? = null + override var influenceType: InfluenceType? = null + override var indirectIds: JSONArray? = null + override var directId: String? = null - @get:Throws(JSONException::class) - abstract val lastChannelObjects: JSONArray - abstract val channelLimit: Int - abstract val indirectAttributionWindow: Int + @get:Throws(JSONException::class) + abstract val lastChannelObjects: JSONArray + abstract val channelLimit: Int + abstract val indirectAttributionWindow: Int - abstract fun getLastChannelObjectsReceivedByNewId(id: String?): JSONArray + abstract fun getLastChannelObjectsReceivedByNewId(id: String?): JSONArray - abstract fun saveChannelObjects(channelObjects: JSONArray) + abstract fun saveChannelObjects(channelObjects: JSONArray) - abstract fun initInfluencedTypeFromCache() + abstract fun initInfluencedTypeFromCache() - private val isDirectSessionEnabled: Boolean - get() = dataRepository.isDirectInfluenceEnabled + private val isDirectSessionEnabled: Boolean + get() = dataRepository.isDirectInfluenceEnabled - private val isIndirectSessionEnabled: Boolean - get() = dataRepository.isIndirectInfluenceEnabled + private val isIndirectSessionEnabled: Boolean + get() = dataRepository.isIndirectInfluenceEnabled - private val isUnattributedSessionEnabled: Boolean - get() = dataRepository.isUnattributedInfluenceEnabled + private val isUnattributedSessionEnabled: Boolean + get() = dataRepository.isUnattributedInfluenceEnabled - /** - * Get the current session based on state + if outcomes features are enabled. - */ - override val currentSessionInfluence: Influence - get() { - val sessionInfluence = Influence(channelType, InfluenceType.DISABLED, null) - // Channel weren't init yet because application is starting - if (influenceType == null) initInfluencedTypeFromCache() + /** + * Get the current session based on state + if outcomes features are enabled. + */ + override val currentSessionInfluence: Influence + get() { + val sessionInfluence = Influence(channelType, InfluenceType.DISABLED, null) + // Channel weren't init yet because application is starting + if (influenceType == null) initInfluencedTypeFromCache() - val currentInfluenceType = influenceType ?: InfluenceType.DISABLED + val currentInfluenceType = influenceType ?: InfluenceType.DISABLED - if (currentInfluenceType.isDirect()) { - if (isDirectSessionEnabled) { - sessionInfluence.apply { - ids = JSONArray().put(this@ChannelTracker.directId) - influenceType = InfluenceType.DIRECT + if (currentInfluenceType.isDirect()) { + if (isDirectSessionEnabled) { + sessionInfluence.apply { + ids = JSONArray().put(this@ChannelTracker.directId) + influenceType = InfluenceType.DIRECT + } } - } - } else if (currentInfluenceType.isIndirect()) { - if (isIndirectSessionEnabled) { + } else if (currentInfluenceType.isIndirect()) { + if (isIndirectSessionEnabled) { + sessionInfluence.apply { + ids = this@ChannelTracker.indirectIds + influenceType = InfluenceType.INDIRECT + } + } + } else if (isUnattributedSessionEnabled) { sessionInfluence.apply { - ids = this@ChannelTracker.indirectIds - influenceType = InfluenceType.INDIRECT + influenceType = InfluenceType.UNATTRIBUTED } } - } else if (isUnattributedSessionEnabled) { - sessionInfluence.apply { - influenceType = InfluenceType.UNATTRIBUTED - } - } - return sessionInfluence - } + return sessionInfluence + } - /** - * Get all received ids that may influence actions - * @return ids that happen between attribution window - */ - override val lastReceivedIds: JSONArray - get() { - val ids = JSONArray() - try { - val lastChannelObjectReceived = lastChannelObjects - Logging.debug("ChannelTracker.getLastReceivedIds: lastChannelObjectReceived: $lastChannelObjectReceived") - val attributionWindow = indirectAttributionWindow * 60 * 1000L - val currentTime = timeProvider.currentTimeMillis - for (i in 0 until lastChannelObjectReceived.length()) { - val jsonObject = lastChannelObjectReceived.getJSONObject(i) - val time = jsonObject.getLong(InfluenceConstants.TIME) - val difference = currentTime - time - if (difference <= attributionWindow) { - val id = jsonObject.getString(idTag) - ids.put(id) + /** + * Get all received ids that may influence actions + * @return ids that happen between attribution window + */ + override val lastReceivedIds: JSONArray + get() { + val ids = JSONArray() + try { + val lastChannelObjectReceived = lastChannelObjects + Logging.debug("ChannelTracker.getLastReceivedIds: lastChannelObjectReceived: $lastChannelObjectReceived") + val attributionWindow = indirectAttributionWindow * 60 * 1000L + val currentTime = timeProvider.currentTimeMillis + for (i in 0 until lastChannelObjectReceived.length()) { + val jsonObject = lastChannelObjectReceived.getJSONObject(i) + val time = jsonObject.getLong(InfluenceConstants.TIME) + val difference = currentTime - time + if (difference <= attributionWindow) { + val id = jsonObject.getString(idTag) + ids.put(id) + } } + } catch (exception: JSONException) { + Logging.error("ChannelTracker.getLastReceivedIds: Generating tracker getLastReceivedIds JSONObject ", exception) } - } catch (exception: JSONException) { - Logging.error("ChannelTracker.getLastReceivedIds: Generating tracker getLastReceivedIds JSONObject ", exception) + return ids } - return ids + + override fun resetAndInitInfluence() { + directId = null + indirectIds = lastReceivedIds + influenceType = if (indirectIds?.length() ?: 0 > 0) InfluenceType.INDIRECT else InfluenceType.UNATTRIBUTED + cacheState() + Logging.debug("ChannelTracker.resetAndInitInfluence: $idTag finish with influenceType: $influenceType") } - override fun resetAndInitInfluence() { - directId = null - indirectIds = lastReceivedIds - influenceType = if (indirectIds?.length() ?: 0 > 0) InfluenceType.INDIRECT else InfluenceType.UNATTRIBUTED - cacheState() - Logging.debug("ChannelTracker.resetAndInitInfluence: $idTag finish with influenceType: $influenceType") - } + /** + * Save state of last ids received + */ + override fun saveLastId(id: String?) { + Logging.debug("ChannelTracker.saveLastId(id: $id): idTag=$idTag") + if (id == null || id.isEmpty()) return + + val lastChannelObjectsReceived = getLastChannelObjectsReceivedByNewId(id) + Logging.debug("ChannelTracker.saveLastId: for $idTag saveLastId with lastChannelObjectsReceived: $lastChannelObjectsReceived") - /** - * Save state of last ids received - */ - override fun saveLastId(id: String?) { - Logging.debug("ChannelTracker.saveLastId(id: $id): idTag=$idTag") - if (id == null || id.isEmpty()) return - - val lastChannelObjectsReceived = getLastChannelObjectsReceivedByNewId(id) - Logging.debug("ChannelTracker.saveLastId: for $idTag saveLastId with lastChannelObjectsReceived: $lastChannelObjectsReceived") - - try { - timeProvider.run { - JSONObject() - .put(idTag, id) - .put(InfluenceConstants.TIME, currentTimeMillis) - }.also { newInfluenceId -> - lastChannelObjectsReceived.put(newInfluenceId) + try { + timeProvider.run { + JSONObject() + .put(idTag, id) + .put(InfluenceConstants.TIME, currentTimeMillis) + }.also { newInfluenceId -> + lastChannelObjectsReceived.put(newInfluenceId) + } + } catch (exception: JSONException) { + Logging.error("ChannelTracker.saveLastId: Generating tracker newInfluenceId JSONObject ", exception) + // We don't have new data, stop logic + return } - } catch (exception: JSONException) { - Logging.error("ChannelTracker.saveLastId: Generating tracker newInfluenceId JSONObject ", exception) - // We don't have new data, stop logic - return - } - var channelObjectToSave = lastChannelObjectsReceived - // Only save the last ids without surpassing the limit - // Always keep the max quantity of ids possible - // If the attribution window increases, old ids might influence - if (lastChannelObjectsReceived.length() > channelLimit) { - val lengthDifference = lastChannelObjectsReceived.length() - channelLimit - // If min sdk is greater than KITKAT we can refactor this logic to removeObject from JSONArray - channelObjectToSave = JSONArray() - for (i in lengthDifference until lastChannelObjectsReceived.length()) { - try { - channelObjectToSave.put(lastChannelObjectsReceived[i]) - } catch (exception: JSONException) { - Logging.error("ChannelTracker.saveLastId: Generating tracker lastChannelObjectsReceived get JSONObject ", exception) + var channelObjectToSave = lastChannelObjectsReceived + // Only save the last ids without surpassing the limit + // Always keep the max quantity of ids possible + // If the attribution window increases, old ids might influence + if (lastChannelObjectsReceived.length() > channelLimit) { + val lengthDifference = lastChannelObjectsReceived.length() - channelLimit + // If min sdk is greater than KITKAT we can refactor this logic to removeObject from JSONArray + channelObjectToSave = JSONArray() + for (i in lengthDifference until lastChannelObjectsReceived.length()) { + try { + channelObjectToSave.put(lastChannelObjectsReceived[i]) + } catch (exception: JSONException) { + Logging.error("ChannelTracker.saveLastId: Generating tracker lastChannelObjectsReceived get JSONObject ", exception) + } } } + Logging.debug("ChannelTracker.saveLastId: for $idTag with channelObjectToSave: $channelObjectToSave") + saveChannelObjects(channelObjectToSave) } - Logging.debug("ChannelTracker.saveLastId: for $idTag with channelObjectToSave: $channelObjectToSave") - saveChannelObjects(channelObjectToSave) - } - override fun toString(): String { - return "ChannelTracker{" + - "tag=" + idTag + - ", influenceType=" + influenceType + - ", indirectIds=" + indirectIds + - ", directId=" + directId + - '}' - } + override fun toString(): String { + return "ChannelTracker{" + + "tag=" + idTag + + ", influenceType=" + influenceType + + ", indirectIds=" + indirectIds + + ", directId=" + directId + + '}' + } - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || javaClass != other.javaClass) return false - val tracker = other as ChannelTracker - return influenceType === tracker.influenceType && tracker.idTag == idTag - } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + val tracker = other as ChannelTracker + return influenceType === tracker.influenceType && tracker.idTag == idTag + } - override fun hashCode(): Int { - var result = influenceType.hashCode() - result = 31 * result + idTag.hashCode() - return result + override fun hashCode(): Int { + var result = influenceType.hashCode() + result = 31 * result + idTag.hashCode() + return result + } } -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeEventsController.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeEventsController.kt index 8c230f352a..789fafbbdc 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeEventsController.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeEventsController.kt @@ -1,8 +1,9 @@ package com.onesignal.session.internal.outcomes.impl +import android.os.Process import com.onesignal.common.NetworkUtils import com.onesignal.common.exceptions.BackendException -import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.device.IDeviceService import com.onesignal.core.internal.startup.IStartableService @@ -41,7 +42,7 @@ internal class OutcomeEventsController( } override fun start() { - suspendifyOnIO { + suspendifyOnThread { sendSavedOutcomes() _outcomeEventsCache.cleanCachedUniqueOutcomeEventNotifications() } @@ -282,7 +283,7 @@ internal class OutcomeEventsController( * Save the ATTRIBUTED JSONArray of notification ids with unique outcome names to SQL */ private fun saveAttributedUniqueOutcomeNotifications(eventParams: OutcomeEventParams) { - suspendifyOnIO { + suspendifyOnThread(Process.THREAD_PRIORITY_BACKGROUND) { _outcomeEventsCache.saveUniqueOutcomeEventParams(eventParams) } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeSourceBody.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeSourceBody.kt index 94730862ca..0d6905d3fb 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeSourceBody.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeSourceBody.kt @@ -5,18 +5,18 @@ import org.json.JSONException import org.json.JSONObject internal class OutcomeSourceBody -@JvmOverloads -constructor(var notificationIds: JSONArray? = JSONArray(), var inAppMessagesIds: JSONArray? = JSONArray()) { - @Throws(JSONException::class) - fun toJSONObject(): JSONObject = - JSONObject() - .put(OutcomeConstants.NOTIFICATION_IDS, notificationIds) - .put(OutcomeConstants.IAM_IDS, inAppMessagesIds) + @JvmOverloads + constructor(var notificationIds: JSONArray? = JSONArray(), var inAppMessagesIds: JSONArray? = JSONArray()) { + @Throws(JSONException::class) + fun toJSONObject(): JSONObject = + JSONObject() + .put(OutcomeConstants.NOTIFICATION_IDS, notificationIds) + .put(OutcomeConstants.IAM_IDS, inAppMessagesIds) - override fun toString(): String { - return "OutcomeSourceBody{" + - "notificationIds=" + notificationIds + - ", inAppMessagesIds=" + inAppMessagesIds + - '}' + override fun toString(): String { + return "OutcomeSourceBody{" + + "notificationIds=" + notificationIds + + ", inAppMessagesIds=" + inAppMessagesIds + + '}' + } } -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt index 2b31f30da9..8d2161aa65 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt @@ -1,6 +1,6 @@ package com.onesignal.session.internal.session.impl -import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.IOperationRepo import com.onesignal.core.internal.startup.IStartableService @@ -58,7 +58,7 @@ internal class SessionListener( TrackSessionEndOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId, durationInSeconds), ) - suspendifyOnIO { + suspendifyOnThread { _outcomeEventsController.sendSessionEndOutcomeEvent(durationInSeconds) } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/AppIdResolution.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/AppIdResolution.kt deleted file mode 100644 index b16bd7475c..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/AppIdResolution.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.onesignal.user.internal - -import com.onesignal.core.internal.config.ConfigModel -import com.onesignal.core.internal.preferences.IPreferencesService -import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys -import com.onesignal.core.internal.preferences.PreferenceStores - -data class AppIdResolution( - val appId: String?, - val forceCreateUser: Boolean, - val failed: Boolean, -) - -fun resolveAppId( - inputAppId: String?, - configModel: ConfigModel, - preferencesService: IPreferencesService, -): AppIdResolution { - // Case 1: AppId provided as input - if (inputAppId != null) { - val forceCreateUser = !configModel.hasProperty(ConfigModel::appId.name) || configModel.appId != inputAppId - return AppIdResolution(appId = inputAppId, forceCreateUser = forceCreateUser, failed = false) - } - - // Case 2: No appId provided, but configModel has one - if (configModel.hasProperty(ConfigModel::appId.name)) { - return AppIdResolution(appId = configModel.appId, forceCreateUser = false, failed = false) - } - - // Case 3: No appId provided, no configModel appId - try legacy - val legacyAppId = preferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) - if (legacyAppId != null) { - return AppIdResolution(appId = legacyAppId, forceCreateUser = true, failed = false) - } - - return AppIdResolution(appId = null, forceCreateUser = false, failed = true) -} 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 deleted file mode 100644 index ae45985dfc..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.onesignal.user.internal - -import com.onesignal.core.internal.config.ConfigModel -import com.onesignal.core.internal.operations.IOperationRepo -import com.onesignal.debug.internal.logging.Logging -import com.onesignal.user.internal.identity.IdentityModelStore -import com.onesignal.user.internal.operations.LoginUserOperation - -class LoginHelper( - private val identityModelStore: IdentityModelStore, - private val userSwitcher: UserSwitcher, - private val operationRepo: IOperationRepo, - private val configModel: ConfigModel, - private val lock: Any, -) { - suspend fun login( - externalId: String, - jwtBearerToken: String? = null, - ) { - var currentIdentityExternalId: String? = null - var currentIdentityOneSignalId: String? = null - var newIdentityOneSignalId: String = "" - - synchronized(lock) { - currentIdentityExternalId = identityModelStore.model.externalId - currentIdentityOneSignalId = identityModelStore.model.onesignalId - - if (currentIdentityExternalId == externalId) { - return - } - - // TODO: Set JWT Token for all future requests. - userSwitcher.createAndSwitchToNewUser { identityModel, _ -> - identityModel.externalId = externalId - } - - newIdentityOneSignalId = identityModelStore.model.onesignalId - } - - val result = - operationRepo.enqueueAndWait( - LoginUserOperation( - configModel.appId, - newIdentityOneSignalId, - externalId, - if (currentIdentityExternalId == null) currentIdentityOneSignalId else null, - ), - ) - - if (!result) { - Logging.error("Could not login user") - } - } -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt deleted file mode 100644 index 8d9015c612..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.onesignal.user.internal - -import com.onesignal.core.internal.config.ConfigModel -import com.onesignal.core.internal.operations.IOperationRepo -import com.onesignal.user.internal.identity.IdentityModelStore -import com.onesignal.user.internal.operations.LoginUserOperation - -class LogoutHelper( - private val identityModelStore: IdentityModelStore, - private val userSwitcher: UserSwitcher, - private val operationRepo: IOperationRepo, - private val configModel: ConfigModel, - private val lock: Any, -) { - fun logout() { - synchronized(lock) { - if (identityModelStore.model.externalId == null) { - return - } - - // Create new device-scoped user (clears external ID) - userSwitcher.createAndSwitchToNewUser() - - // Enqueue login operation for the new device-scoped user (no external ID) - operationRepo.enqueue( - LoginUserOperation( - configModel.appId, - identityModelStore.model.onesignalId, - null, - // No external ID for device-scoped user - ), - ) - - // TODO: remove JWT Token for all future requests. - } - } -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserSwitcher.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserSwitcher.kt deleted file mode 100644 index 5fba367b1a..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserSwitcher.kt +++ /dev/null @@ -1,182 +0,0 @@ -package com.onesignal.user.internal - -import android.content.Context -import com.onesignal.common.AndroidUtils -import com.onesignal.common.IDManager -import com.onesignal.common.OneSignalUtils -import com.onesignal.common.modeling.ModelChangeTags -import com.onesignal.common.safeInt -import com.onesignal.common.safeString -import com.onesignal.common.services.ServiceProvider -import com.onesignal.core.internal.application.IApplicationService -import com.onesignal.core.internal.config.ConfigModel -import com.onesignal.core.internal.operations.IOperationRepo -import com.onesignal.core.internal.preferences.IPreferencesService -import com.onesignal.core.internal.preferences.clearLegacyPlayerId -import com.onesignal.core.internal.preferences.getLegacyPlayerId -import com.onesignal.core.internal.preferences.getLegacyUserSyncValues -import com.onesignal.debug.internal.logging.Logging -import com.onesignal.user.internal.identity.IdentityModel -import com.onesignal.user.internal.identity.IdentityModelStore -import com.onesignal.user.internal.identity.hasOneSignalId -import com.onesignal.user.internal.operations.LoginUserFromSubscriptionOperation -import com.onesignal.user.internal.operations.LoginUserOperation -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 -import org.json.JSONObject - -class UserSwitcher( - private val preferencesService: IPreferencesService, - private val operationRepo: IOperationRepo, - private val services: ServiceProvider, - private val idManager: IDManager = IDManager, - private val identityModelStore: IdentityModelStore, - private val propertiesModelStore: PropertiesModelStore, - private val subscriptionModelStore: SubscriptionModelStore, - private val configModel: ConfigModel, - private val oneSignalUtils: OneSignalUtils = OneSignalUtils, - private val carrierName: String? = null, - private val deviceOS: String? = null, - private val androidUtils: AndroidUtils = AndroidUtils, - private val appContextProvider: () -> Context, -) { - fun createAndSwitchToNewUser( - suppressBackendOperation: Boolean = false, - modify: ((identityModel: IdentityModel, propertiesModel: PropertiesModel) -> Unit)? = null, - ) { - Logging.debug("createAndSwitchToNewUser()") - - val sdkId = idManager.createLocalId() - - val identityModel = IdentityModel().apply { onesignalId = sdkId } - val propertiesModel = PropertiesModel().apply { onesignalId = sdkId } - - modify?.invoke(identityModel, propertiesModel) - - val subscriptions = mutableListOf() - val currentPushSubscription = - subscriptionModelStore.list() - .firstOrNull { it.id == configModel.pushSubscriptionId } - val newPushSubscription = - SubscriptionModel().apply { - id = currentPushSubscription?.id ?: idManager.createLocalId() - type = SubscriptionType.PUSH - optedIn = currentPushSubscription?.optedIn ?: true - address = currentPushSubscription?.address ?: "" - status = currentPushSubscription?.status ?: SubscriptionStatus.NO_PERMISSION - sdk = oneSignalUtils.sdkVersion - deviceOS = this@UserSwitcher.deviceOS ?: "" - carrier = carrierName ?: "" - appVersion = androidUtils.getAppVersion(appContextProvider()) ?: "" - } - - configModel.pushSubscriptionId = newPushSubscription.id - subscriptions.add(newPushSubscription) - - subscriptionModelStore.clear(ModelChangeTags.NO_PROPOGATE) - identityModelStore.replace(identityModel) - propertiesModelStore.replace(propertiesModel) - - if (suppressBackendOperation) { - subscriptionModelStore.replaceAll(subscriptions, ModelChangeTags.NO_PROPOGATE) - } else { - subscriptionModelStore.replaceAll(subscriptions) - } - } - - fun createPushSubscriptionFromLegacySync( - legacyPlayerId: String, - legacyUserSyncJSON: JSONObject, - configModel: ConfigModel, - subscriptionModelStore: SubscriptionModelStore, - appContext: Context, - ): Boolean { - val notificationTypes = legacyUserSyncJSON.safeInt("notification_types") - - val pushSubscriptionModel = - SubscriptionModel().apply { - id = legacyPlayerId - type = SubscriptionType.PUSH - optedIn = notificationTypes != SubscriptionStatus.NO_PERMISSION.value && - notificationTypes != SubscriptionStatus.UNSUBSCRIBE.value - address = legacyUserSyncJSON.safeString("identifier") ?: "" - status = notificationTypes?.let { SubscriptionStatus.fromInt(it) } - ?: SubscriptionStatus.SUBSCRIBED - sdk = OneSignalUtils.sdkVersion - deviceOS = this@UserSwitcher.deviceOS ?: "" - carrier = carrierName ?: "" - appVersion = AndroidUtils.getAppVersion(appContext) ?: "" - } - - configModel.pushSubscriptionId = legacyPlayerId - subscriptionModelStore.add(pushSubscriptionModel, ModelChangeTags.NO_PROPOGATE) - return true - } - - fun initUser(forceCreateUser: Boolean) { - if (forceCreateUser || !identityModelStore.hasOneSignalId()) { - val legacyPlayerId = preferencesService.getLegacyPlayerId() - - if (legacyPlayerId == null) { - createNewUser() - } else { - migrateFromLegacyUser(legacyPlayerId) - } - } else { - Logging.debug("initWithContext: using cached user ${identityModelStore.model.onesignalId}") - } - } - - /** - * Creates a new device-scoped user with no legacy data. - */ - private fun createNewUser() { - Logging.debug("initWithContext: creating new device-scoped user") - createAndSwitchToNewUser() - operationRepo.enqueue( - LoginUserOperation( - configModel.appId, - identityModelStore.model.onesignalId, - identityModelStore.model.externalId, - ), - ) - } - - /** - * Migrates from a v4 SDK user by creating a new user linked to the legacy subscription. - * This handles the conversion from 4.x SDK to 5.x SDK format. - */ - private fun migrateFromLegacyUser(legacyPlayerId: String) { - Logging.debug("initWithContext: creating user linked to subscription $legacyPlayerId") - - val legacyUserSyncString = preferencesService.getLegacyUserSyncValues() - var suppressBackendOperation = false - - if (legacyUserSyncString != null) { - createPushSubscriptionFromLegacySync( - legacyPlayerId = legacyPlayerId, - legacyUserSyncJSON = JSONObject(legacyUserSyncString), - configModel = configModel, - subscriptionModelStore = subscriptionModelStore, - appContext = services.getService().appContext, - ) - suppressBackendOperation = true - } - - createAndSwitchToNewUser(suppressBackendOperation = suppressBackendOperation) - - operationRepo.enqueue( - LoginUserFromSubscriptionOperation( - configModel.appId, - identityModelStore.model.onesignalId, - legacyPlayerId, - ), - ) - - preferencesService.clearLegacyPlayerId() - } -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/IdentityModelStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/IdentityModelStore.kt index 9a9355647a..911c4ba71b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/IdentityModelStore.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/IdentityModelStore.kt @@ -3,14 +3,7 @@ package com.onesignal.user.internal.identity import com.onesignal.common.modeling.SimpleModelStore import com.onesignal.common.modeling.SingletonModelStore import com.onesignal.core.internal.preferences.IPreferencesService -import com.onesignal.user.internal.backend.IdentityConstants open class IdentityModelStore(prefs: IPreferencesService) : SingletonModelStore( SimpleModelStore({ IdentityModel() }, "identity", prefs), ) - -/** - * Checks if the identity model has a OneSignal ID. - * Used to determine if a user is already initialized or needs to be created. - */ -fun IdentityModelStore.hasOneSignalId(): Boolean = model.hasProperty(IdentityConstants.ONESIGNAL_ID) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/migrations/RecoverFromDroppedLoginBug.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/migrations/RecoverFromDroppedLoginBug.kt index 2cf7b39c49..b763a0d28e 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/migrations/RecoverFromDroppedLoginBug.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/migrations/RecoverFromDroppedLoginBug.kt @@ -1,7 +1,6 @@ package com.onesignal.user.internal.migrations import com.onesignal.common.IDManager -import com.onesignal.common.threading.suspendifyOnIO import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.IOperationRepo import com.onesignal.core.internal.operations.containsInstanceOf @@ -9,6 +8,9 @@ import com.onesignal.core.internal.startup.IStartableService import com.onesignal.debug.internal.logging.Logging import com.onesignal.user.internal.identity.IdentityModelStore import com.onesignal.user.internal.operations.LoginUserOperation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch /** * Purpose: Automatically recovers a stalled User in the OperationRepo due @@ -33,7 +35,7 @@ class RecoverFromDroppedLoginBug( private val _configModelStore: ConfigModelStore, ) : IStartableService { override fun start() { - suspendifyOnIO { + GlobalScope.launch(Dispatchers.IO) { _operationRepo.awaitInitialized() if (isInBadState()) { Logging.warn( diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserFromSubscriptionOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserFromSubscriptionOperationExecutor.kt index 84093eeccb..9a1178999d 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserFromSubscriptionOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserFromSubscriptionOperationExecutor.kt @@ -47,7 +47,7 @@ internal class LoginUserFromSubscriptionOperationExecutor( loginUserOp.appId, loginUserOp.subscriptionId, ) - val backendOneSignalId = identities[IdentityConstants.ONESIGNAL_ID] ?: null + val backendOneSignalId = identities.getOrDefault(IdentityConstants.ONESIGNAL_ID, null) if (backendOneSignalId == null) { Logging.warn("Subscription ${loginUserOp.subscriptionId} has no ${IdentityConstants.ONESIGNAL_ID}!") diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt index 81ab0bb687..1e54d4fc27 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt @@ -216,17 +216,17 @@ internal class SubscriptionOperationExecutor( ExecutionResponse( ExecutionResult.FAIL_NORETRY, operations = - listOf( - CreateSubscriptionOperation( - lastOperation.appId, - lastOperation.onesignalId, - lastOperation.subscriptionId, - lastOperation.type, - lastOperation.enabled, - lastOperation.address, - lastOperation.status, + listOf( + CreateSubscriptionOperation( + lastOperation.appId, + lastOperation.onesignalId, + lastOperation.subscriptionId, + lastOperation.type, + lastOperation.enabled, + lastOperation.address, + lastOperation.status, + ), ), - ), ) } else -> diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/consistency/ConsistencyManagerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/consistency/ConsistencyManagerTests.kt index 888b95605f..a374487a16 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/consistency/ConsistencyManagerTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/consistency/ConsistencyManagerTests.kt @@ -6,7 +6,7 @@ import com.onesignal.common.consistency.models.ICondition import com.onesignal.common.consistency.models.IConsistencyKeyEnum import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest class ConsistencyManagerTests : FunSpec({ @@ -17,7 +17,7 @@ class ConsistencyManagerTests : FunSpec({ } test("setRywToken updates the token correctly") { - runBlocking { + runTest { // Given val id = "test_id" val key = IamFetchRywTokenKey.USER @@ -36,7 +36,7 @@ class ConsistencyManagerTests : FunSpec({ } test("registerCondition completes when condition is met") { - runBlocking { + runTest { // Given val id = "test_id" val key = IamFetchRywTokenKey.USER @@ -56,7 +56,7 @@ class ConsistencyManagerTests : FunSpec({ } test("registerCondition does not complete when condition is not met") { - runBlocking { + runTest { val condition = TestUnmetCondition() val deferred = consistencyManager.getRywDataFromAwaitableCondition(condition) @@ -66,7 +66,7 @@ class ConsistencyManagerTests : FunSpec({ } test("resolveConditionsWithID resolves conditions based on ID") { - runBlocking { + runTest { val condition = TestUnmetCondition() val deferred = consistencyManager.getRywDataFromAwaitableCondition(condition) consistencyManager.resolveConditionsWithID(TestUnmetCondition.ID) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/CompletionAwaiterTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/CompletionAwaiterTests.kt deleted file mode 100644 index 37f239ead3..0000000000 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/CompletionAwaiterTests.kt +++ /dev/null @@ -1,363 +0,0 @@ -package com.onesignal.common.threading - -import com.onesignal.common.AndroidUtils -import com.onesignal.debug.LogLevel -import com.onesignal.debug.internal.logging.Logging -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.longs.shouldBeGreaterThan -import io.kotest.matchers.longs.shouldBeLessThan -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockkObject -import io.mockk.unmockkObject -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.delay -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking - -class CompletionAwaiterTests : FunSpec({ - - lateinit var awaiter: CompletionAwaiter - - beforeEach { - Logging.logLevel = LogLevel.NONE - awaiter = CompletionAwaiter("TestComponent") - } - - afterEach { - unmockkObject(AndroidUtils) - } - - context("blocking await functionality") { - - test("await completes immediately when already completed") { - // Given - awaiter.complete() - - // When - val startTime = System.currentTimeMillis() - val completed = awaiter.await(1000) - val duration = System.currentTimeMillis() - startTime - - // Then - completed shouldBe true - duration shouldBeLessThan 50L // Should be very fast - } - - test("await waits for delayed completion") { - val completionDelay = 300L - val timeoutMs = 2000L - - val startTime = System.currentTimeMillis() - - // Simulate delayed completion from another thread - suspendifyOnIO { - delay(completionDelay) - awaiter.complete() - } - - val result = awaiter.await(timeoutMs) - val duration = System.currentTimeMillis() - startTime - - result shouldBe true - duration shouldBeGreaterThan (completionDelay - 50) - duration shouldBeLessThan (completionDelay + 150) // buffer - } - - test("await returns false when timeout expires") { - mockkObject(AndroidUtils) - every { AndroidUtils.isRunningOnMainThread() } returns false - - val timeoutMs = 200L - val startTime = System.currentTimeMillis() - - val completed = awaiter.await(timeoutMs) - val duration = System.currentTimeMillis() - startTime - - completed shouldBe false - duration shouldBeGreaterThan (timeoutMs - 50) - duration shouldBeLessThan (timeoutMs + 150) - } - - test("await timeout of 0 returns false immediately when not completed") { - // Mock AndroidUtils to avoid Looper.getMainLooper() issues - mockkObject(AndroidUtils) - every { AndroidUtils.isRunningOnMainThread() } returns false - - val startTime = System.currentTimeMillis() - val completed = awaiter.await(0) - val duration = System.currentTimeMillis() - startTime - - completed shouldBe false - duration shouldBeLessThan 20L - - unmockkObject(AndroidUtils) - } - - test("multiple blocking callers are all unblocked") { - val numCallers = 5 - val results = mutableListOf() - val jobs = mutableListOf() - - // Start multiple blocking callers - repeat(numCallers) { index -> - val thread = - Thread { - val result = awaiter.await(2000) - synchronized(results) { - results.add(result) - } - } - thread.start() - jobs.add(thread) - } - - // Wait a bit to ensure all threads are waiting - Thread.sleep(100) - - // Complete the awaiter - awaiter.complete() - - // Wait for all threads to complete - jobs.forEach { it.join(1000) } - - // All should have completed successfully - results.size shouldBe numCallers - results.all { it } shouldBe true - } - } - - context("suspend await functionality") { - - test("awaitSuspend completes immediately when already completed") { - runBlocking { - // Given - awaiter.complete() - - // When - should complete immediately without hanging - awaiter.awaitSuspend() - - // Then - if we get here, it completed successfully - // No timing assertions needed in test environment - } - } - - test("awaitSuspend waits for delayed completion") { - runBlocking { - val completionDelay = 100L - - // Start delayed completion - val completionJob = - launch { - delay(completionDelay) - awaiter.complete() - } - - // Wait for completion - awaiter.awaitSuspend() - - // In test environment, we just verify it completed without hanging - completionJob.join() - } - } - - test("multiple suspend callers are all unblocked") { - runBlocking { - val numCallers = 5 - val results = mutableListOf() - - // Start multiple suspend callers - val jobs = - (1..numCallers).map { index -> - async { - awaiter.awaitSuspend() - results.add("caller-$index") - } - } - - // Wait a bit to ensure all coroutines are suspended - delay(50) - - // Complete the awaiter - awaiter.complete() - - // Wait for all callers to complete - jobs.awaitAll() - - // All should have completed - results.size shouldBe numCallers - } - } - - test("awaitSuspend can be cancelled") { - runBlocking { - val job = - launch { - awaiter.awaitSuspend() - } - - // Wait a bit then cancel - delay(50) - job.cancel() - - // Job should be cancelled - job.isCancelled shouldBe true - } - } - } - - context("mixed blocking and suspend callers") { - - test("completion unblocks both blocking and suspend callers") { - // This test verifies the dual mechanism works - // We'll test blocking and suspend separately since mixing them in runTest is problematic - - // Test suspend callers first - runBlocking { - val suspendResults = mutableListOf() - - // Start suspend callers - val suspendJobs = - (1..2).map { index -> - async { - awaiter.awaitSuspend() - suspendResults.add("suspend-$index") - } - } - - // Wait a bit to ensure all are waiting - delay(50) - - // Complete the awaiter - awaiter.complete() - - // Wait for all to complete - suspendJobs.awaitAll() - - // All should have completed - suspendResults.size shouldBe 2 - } - - // Reset for blocking test - awaiter = CompletionAwaiter("TestComponent") - - // Test blocking callers - val blockingResults = mutableListOf() - val blockingThreads = - (1..2).map { index -> - Thread { - val result = awaiter.await(2000) - synchronized(blockingResults) { - blockingResults.add(result) - } - } - } - blockingThreads.forEach { it.start() } - - // Wait a bit to ensure all are waiting - Thread.sleep(100) - - // Complete the awaiter - awaiter.complete() - - // Wait for all to complete - blockingThreads.forEach { it.join(1000) } - - // All should have completed - blockingResults shouldBe arrayOf(true, true) - } - } - - context("edge cases and safety") { - - test("multiple complete calls are safe") { - // Complete multiple times - awaiter.complete() - awaiter.complete() - awaiter.complete() - - // Should still work normally - val completed = awaiter.await(100) - completed shouldBe true - } - - test("waiting after completion returns immediately") { - runBlocking { - // Complete first - awaiter.complete() - - // Then wait - should return immediately without hanging - awaiter.awaitSuspend() - - // Multiple calls should also work immediately - awaiter.awaitSuspend() - awaiter.awaitSuspend() - } - } - - test("concurrent access is safe") { - runBlocking { - val numOperations = 10 // Reduced for test stability - val jobs = mutableListOf() - - // Start some waiters first - repeat(numOperations / 2) { index -> - jobs.add( - async { - awaiter.awaitSuspend() - }, - ) - } - - // Wait a bit for them to start waiting - delay(10) - - // Then complete multiple times concurrently - repeat(numOperations / 2) { index -> - jobs.add(launch { awaiter.complete() }) - } - - // Wait for all operations - jobs.joinAll() - - // Final wait should work immediately - awaiter.awaitSuspend() - } - } - } - - context("timeout behavior") { - - test("uses shorter timeout on main thread") { - mockkObject(AndroidUtils) - every { AndroidUtils.isRunningOnMainThread() } returns true - - val startTime = System.currentTimeMillis() - val completed = awaiter.await() // Default timeout - val duration = System.currentTimeMillis() - startTime - - completed shouldBe false - // Should use ANDROID_ANR_TIMEOUT_MS (4800ms) instead of DEFAULT_TIMEOUT_MS (30000ms) - duration shouldBeLessThan 6000L // Much less than 30 seconds - duration shouldBeGreaterThan 4000L // But around 4.8 seconds - } - - test("uses longer timeout on background thread") { - mockkObject(AndroidUtils) - every { AndroidUtils.isRunningOnMainThread() } returns false - - // We can't actually wait 30 seconds in a test, so just verify it would use the longer timeout - // by checking the timeout logic doesn't kick in quickly - val startTime = System.currentTimeMillis() - val completed = awaiter.await(1000) // Force shorter timeout for test - val duration = System.currentTimeMillis() - startTime - - completed shouldBe false - duration shouldBeGreaterThan 900L - duration shouldBeLessThan 1200L - } - } -}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt deleted file mode 100644 index 72dc5e2b91..0000000000 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.onesignal.common.threading - -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.kotest.matchers.shouldNotBe -import io.kotest.matchers.string.shouldContain -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import java.util.concurrent.CountDownLatch -import java.util.concurrent.atomic.AtomicInteger - -class OneSignalDispatchersTests : FunSpec({ - - beforeAny { - Logging.logLevel = LogLevel.NONE - } - - test("OneSignalDispatchers should be properly initialized") { - // Access dispatchers to trigger initialization - OneSignalDispatchers.IO shouldNotBe null - OneSignalDispatchers.Default shouldNotBe null - } - - test("IO dispatcher should execute work on background thread") { - val mainThreadId = Thread.currentThread().id - var backgroundThreadId: Long? = null - - runBlocking { - withContext(OneSignalDispatchers.IO) { - backgroundThreadId = Thread.currentThread().id - } - } - - backgroundThreadId shouldNotBe null - backgroundThreadId shouldNotBe mainThreadId - } - - test("Default dispatcher should execute work on background thread") { - val mainThreadId = Thread.currentThread().id - var backgroundThreadId: Long? = null - - runBlocking { - withContext(OneSignalDispatchers.Default) { - backgroundThreadId = Thread.currentThread().id - } - } - - backgroundThreadId shouldNotBe null - backgroundThreadId shouldNotBe mainThreadId - } - - test("IOScope should launch coroutines asynchronously") { - var completed = false - - OneSignalDispatchers.launchOnIO { - Thread.sleep(100) - completed = true - } - - Thread.sleep(50) - completed shouldBe false - } - - test("DefaultScope should launch coroutines asynchronously") { - var completed = false - - OneSignalDispatchers.launchOnDefault { - Thread.sleep(100) - completed = true - } - - Thread.sleep(50) - completed shouldBe false - } - - test("getStatus should return meaningful status information") { - val status = OneSignalDispatchers.getStatus() - - status shouldContain "OneSignalDispatchers Status:" - status shouldContain "IO Executor: Active" - status shouldContain "Default Executor: Active" - status shouldContain "IO Scope: Active" - status shouldContain "Default Scope: Active" - } - - test("dispatchers should handle concurrent operations") { - val results = mutableListOf() - val expectedResults = (1..5).toList() - - runBlocking { - (1..5).forEach { i -> - OneSignalDispatchers.launchOnIO { - Thread.sleep(10) - synchronized(results) { - results.add(i) - } - } - } - - Thread.sleep(100) - } - - results.sorted() shouldBe expectedResults - } - - test("multiple concurrent launches should not cause issues") { - val latch = CountDownLatch(5) // Reduced from 20 to 5 - val completed = AtomicInteger(0) - - repeat(5) { i -> // Reduced from 20 to 5 - OneSignalDispatchers.launchOnIO { - delay(10) // Use coroutine delay instead of Thread.sleep - completed.incrementAndGet() - latch.countDown() - } - } - - latch.await() - completed.get() shouldBe 5 // Updated expectation - } - - test("mixed IO and computation tasks should work together") { - val latch = CountDownLatch(10) - val ioCount = AtomicInteger(0) - val compCount = AtomicInteger(0) - - repeat(5) { i -> - OneSignalDispatchers.launchOnIO { - Thread.sleep(20) - ioCount.incrementAndGet() - latch.countDown() - } - - OneSignalDispatchers.launchOnDefault { - Thread.sleep(20) - compCount.incrementAndGet() - latch.countDown() - } - } - - latch.await() - ioCount.get() shouldBe 5 - compCount.get() shouldBe 5 - } - - test("exceptions in one task should not affect others") { - val latch = CountDownLatch(5) - val successCount = AtomicInteger(0) - val errorCount = AtomicInteger(0) - - repeat(5) { i -> - OneSignalDispatchers.launchOnIO { - try { - if (i == 2) { - throw RuntimeException("Test error") - } - Thread.sleep(10) - successCount.incrementAndGet() - } catch (e: Exception) { - errorCount.incrementAndGet() - } finally { - latch.countDown() - } - } - } - - latch.await() - successCount.get() shouldBe 4 - errorCount.get() shouldBe 1 - } -}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/ThreadUtilsTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/ThreadUtilsTests.kt deleted file mode 100644 index 0c372c427b..0000000000 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/ThreadUtilsTests.kt +++ /dev/null @@ -1,344 +0,0 @@ -package com.onesignal.common.threading - -import com.onesignal.debug.LogLevel -import com.onesignal.debug.internal.logging.Logging -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldContain -import io.kotest.matchers.shouldBe -import io.kotest.matchers.shouldNotBe -import kotlinx.coroutines.delay -import java.util.concurrent.CountDownLatch -import java.util.concurrent.atomic.AtomicInteger - -class ThreadUtilsTests : FunSpec({ - - beforeAny { - Logging.logLevel = LogLevel.NONE - } - - test("suspendifyBlocking should execute work synchronously") { - val latch = CountDownLatch(1) - var completed = false - - suspendifyOnDefault { - delay(10) - completed = true - latch.countDown() - } - - latch.await() - completed shouldBe true - } - - test("suspendifyOnMain should execute work asynchronously") { - suspendifyOnMain { - // In test environment, main thread operations may not complete - // The important thing is that it doesn't block the test thread - } - - Thread.sleep(20) - } - - test("suspendifyOnThread should execute work asynchronously") { - val mainThreadId = Thread.currentThread().id - var backgroundThreadId: Long? = null - - suspendifyOnIO { - backgroundThreadId = Thread.currentThread().id - } - - Thread.sleep(10) - backgroundThreadId shouldNotBe null - backgroundThreadId shouldNotBe mainThreadId - } - - test("suspendifyOnThread with completion should execute onComplete callback") { - var completed = false - var onCompleteCalled = false - - suspendifyOnIO( - block = { - Thread.sleep(10) - completed = true - }, - onComplete = { - onCompleteCalled = true - }, - ) - - Thread.sleep(20) - completed shouldBe true - onCompleteCalled shouldBe true - } - - test("suspendifyOnIO should execute work asynchronously") { - val mainThreadId = Thread.currentThread().id - var backgroundThreadId: Long? = null - - suspendifyOnIO { - backgroundThreadId = Thread.currentThread().id - } - - Thread.sleep(10) - backgroundThreadId shouldNotBe null - backgroundThreadId shouldNotBe mainThreadId - } - - test("suspendifyOnIO should execute work on background thread") { - val mainThreadId = Thread.currentThread().id - var backgroundThreadId: Long? = null - - suspendifyOnIO { - backgroundThreadId = Thread.currentThread().id - } - - Thread.sleep(10) - backgroundThreadId shouldNotBe null - backgroundThreadId shouldNotBe mainThreadId - } - - test("suspendifyOnDefault should execute work on background thread") { - val mainThreadId = Thread.currentThread().id - var backgroundThreadId: Long? = null - - suspendifyOnDefault { - backgroundThreadId = Thread.currentThread().id - } - - Thread.sleep(10) - backgroundThreadId shouldNotBe null - backgroundThreadId shouldNotBe mainThreadId - } - - test("suspendifyOnMainModern should execute work on main thread") { - suspendifyOnMain { - // In test environment, main thread operations may not complete - // The important thing is that it doesn't block the test thread - } - - Thread.sleep(20) - } - - test("suspendifyWithCompletion should execute onComplete callback") { - var completed = false - var onCompleteCalled = false - - suspendifyWithCompletion( - useIO = true, - block = { - Thread.sleep(10) - completed = true - }, - onComplete = { - onCompleteCalled = true - }, - ) - - Thread.sleep(20) - completed shouldBe true - onCompleteCalled shouldBe true - } - - test("suspendifyWithErrorHandling should handle errors properly") { - var errorHandled = false - var onCompleteCalled = false - var caughtException: Exception? = null - - suspendifyWithErrorHandling( - useIO = true, - block = { - throw RuntimeException("Test error") - }, - onError = { exception -> - errorHandled = true - caughtException = exception - }, - onComplete = { - onCompleteCalled = true - }, - ) - - Thread.sleep(20) - errorHandled shouldBe true - onCompleteCalled shouldBe false - caughtException?.message shouldBe "Test error" - } - - test("suspendifyWithErrorHandling should call onComplete when no error") { - var errorHandled = false - var onCompleteCalled = false - var completed = false - - suspendifyWithErrorHandling( - useIO = true, - block = { - Thread.sleep(10) - completed = true - }, - onError = { _ -> - errorHandled = true - }, - onComplete = { - onCompleteCalled = true - }, - ) - - Thread.sleep(20) - errorHandled shouldBe false - onCompleteCalled shouldBe true - completed shouldBe true - } - - test("modern functions should handle concurrent operations") { - val results = mutableListOf() - val expectedResults = (1..5).toList() - val latch = CountDownLatch(5) - - (1..5).forEach { i -> - suspendifyOnIO( - block = { - Thread.sleep(20) - synchronized(results) { - results.add(i) - } - }, - onComplete = { - latch.countDown() - }, - ) - } - - latch.await() - results.sorted() shouldBe expectedResults - } - - test("legacy functions should work with modern implementation") { - val latch = CountDownLatch(3) - val completed = AtomicInteger(0) - - suspendifyOnDefault { - Thread.sleep(20) - completed.incrementAndGet() - latch.countDown() - } - - suspendifyOnIO { - Thread.sleep(20) - completed.incrementAndGet() - latch.countDown() - } - - suspendifyOnIO { - Thread.sleep(20) - completed.incrementAndGet() - latch.countDown() - } - - latch.await() - completed.get() shouldBe 3 - } - - test("completion callbacks should work with different dispatchers") { - val latch = CountDownLatch(2) - val ioCompleted = AtomicInteger(0) - val defaultCompleted = AtomicInteger(0) - - suspendifyWithCompletion( - useIO = true, - block = { - Thread.sleep(30) - ioCompleted.incrementAndGet() - }, - onComplete = { latch.countDown() }, - ) - - suspendifyWithCompletion( - useIO = false, - block = { - Thread.sleep(30) - defaultCompleted.incrementAndGet() - }, - onComplete = { latch.countDown() }, - ) - - latch.await() - ioCompleted.get() shouldBe 1 - defaultCompleted.get() shouldBe 1 - } - - test("error handling should work with different dispatchers") { - val latch = CountDownLatch(2) - val ioErrors = AtomicInteger(0) - val defaultErrors = AtomicInteger(0) - - suspendifyWithErrorHandling( - useIO = true, - block = { throw RuntimeException("IO error") }, - onError = { - ioErrors.incrementAndGet() - latch.countDown() - }, - ) - - suspendifyWithErrorHandling( - useIO = false, - block = { throw RuntimeException("Default error") }, - onError = { - defaultErrors.incrementAndGet() - latch.countDown() - }, - ) - - latch.await() - ioErrors.get() shouldBe 1 - defaultErrors.get() shouldBe 1 - } - - test("rapid sequential calls should complete successfully") { - val latch = CountDownLatch(5) - val completed = AtomicInteger(0) - - repeat(5) { _ -> - suspendifyOnIO { - delay(1) - completed.incrementAndGet() - latch.countDown() - } - } - - latch.await() - completed.get() shouldBe 5 - } - - test("mixed legacy and modern functions should work together") { - val latch = CountDownLatch(4) - val results = mutableListOf() - - suspendifyOnDefault { - synchronized(results) { results.add("blocking") } - latch.countDown() - } - - suspendifyOnIO { - synchronized(results) { results.add("thread") } - latch.countDown() - } - - suspendifyOnIO { - synchronized(results) { results.add("io") } - latch.countDown() - } - - suspendifyOnDefault { - synchronized(results) { results.add("default") } - latch.countDown() - } - - latch.await() - results.size shouldBe 4 - results shouldContain "blocking" - results shouldContain "thread" - results shouldContain "io" - results shouldContain "default" - } -}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/ThreadingPerformanceComparisonTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/ThreadingPerformanceComparisonTests.kt deleted file mode 100644 index 379ac291ff..0000000000 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/ThreadingPerformanceComparisonTests.kt +++ /dev/null @@ -1,527 +0,0 @@ -package com.onesignal.common.threading - -import com.onesignal.debug.LogLevel -import com.onesignal.debug.internal.logging.Logging -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.comparables.shouldBeLessThan -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import java.util.concurrent.Executors -import java.util.concurrent.ThreadFactory -import java.util.concurrent.TimeUnit - -// Performance tests - run manually when needed -// To run these tests, set the environment variable: RUN_PERFORMANCE_TESTS=true -class ThreadingPerformanceComparisonTests : FunSpec({ - - beforeAny { - Logging.logLevel = LogLevel.NONE - } - - val runPerformanceTests = System.getenv("RUN_PERFORMANCE_TESTS") == "true" - - test("simple performance test").config(enabled = runPerformanceTests) { - - println("Starting simple performance test...") - - // Test 1: Simple individual thread test - val individualThreadTime = - measureTime { - val threads = mutableListOf() - repeat(10) { i -> - val thread = - Thread { - Thread.sleep(10) // Simulate work - } - threads.add(thread) - thread.start() - } - // Wait for all threads to complete - threads.forEach { it.join() } - } - println("Individual Threads: ${individualThreadTime}ms") - - // Test 2: Simple dispatcher test - val dispatcherTime = - measureTime { - val executor = - Executors.newFixedThreadPool( - 2, - ThreadFactory { r -> - Thread(r, "DispatcherThread-${System.nanoTime()}") - }, - ) - val dispatcher = executor.asCoroutineDispatcher() - - try { - runBlocking { - repeat(10) { i -> - launch(dispatcher) { - Thread.sleep(10) // Simulate work - } - } - } - } finally { - executor.shutdown() - executor.awaitTermination(5, TimeUnit.SECONDS) - } - } - println("Dispatcher (2 threads): ${dispatcherTime}ms") - - // Test 3: OneSignal Dispatchers test (this might be hanging) - println("Testing OneSignal Dispatchers...") - try { - val oneSignalTime = - measureTime { - runBlocking { - repeat(10) { i -> - launch(OneSignalDispatchers.IO) { - Thread.sleep(10) // Simulate work - } - } - } - } - println("OneSignal Dispatchers: ${oneSignalTime}ms") - } catch (e: Exception) { - println("OneSignal Dispatchers failed: ${e.message}") - } - - // Test 4: OneSignal Dispatchers with launchOnIO (this might be hanging) - println("Testing OneSignal launchOnIO...") - try { - val oneSignalFireAndForgetTime = - measureTime { - repeat(10) { i -> - OneSignalDispatchers.launchOnIO { - Thread.sleep(10) // Simulate work - } - } - // Give some time for completion - Thread.sleep(100) - } - println("OneSignal (fire & forget): ${oneSignalFireAndForgetTime}ms") - } catch (e: Exception) { - println("OneSignal launchOnIO failed: ${e.message}") - } - - println("Performance test completed!") - } - - test("dispatcher vs individual threads - execution performance").config(enabled = runPerformanceTests) { - val numberOfOperations = 20 - val workDuration = 50L // ms - val results = mutableMapOf() - - // Test 1: Individual Threads - val individualThreadTime = - measureTime { - val threads = mutableListOf() - repeat(numberOfOperations) { i -> - val thread = - Thread { - Thread.sleep(workDuration) - } - threads.add(thread) - thread.start() - } - threads.forEach { it.join() } - } - results["Individual Threads"] = individualThreadTime - - // Test 2: Dispatcher with 2 threads - val dispatcherTime = - measureTime { - val executor = - Executors.newFixedThreadPool( - 2, - ThreadFactory { r -> - Thread(r, "DispatcherThread-${System.nanoTime()}") - }, - ) - val dispatcher = executor.asCoroutineDispatcher() - - try { - runBlocking { - repeat(numberOfOperations) { i -> - launch(dispatcher) { - Thread.sleep(workDuration) - } - } - } - } finally { - executor.shutdown() - } - } - results["Dispatcher (2 threads)"] = dispatcherTime - - // Test 3: OneSignal Dispatchers - val oneSignalTime = - measureTime { - runBlocking { - repeat(numberOfOperations) { i -> - OneSignalDispatchers.launchOnIO { - Thread.sleep(workDuration) - } - } - } - } - results["OneSignal Dispatchers"] = oneSignalTime - - // Print results - println("\n=== Execution Performance Results ===") - results.forEach { (name, time) -> - println("$name: ${time}ms") - } - - // Dispatcher should be faster than individual threads - dispatcherTime shouldBeLessThan individualThreadTime - oneSignalTime shouldBeLessThan individualThreadTime - } - - test("memory usage comparison").config(enabled = runPerformanceTests) { - val numberOfOperations = 50 - val results = mutableMapOf() - - // Test 1: Individual Threads Memory Usage - val initialMemory1 = getUsedMemory() - val threads = mutableListOf() - repeat(numberOfOperations) { i -> - val thread = - Thread { - Thread.sleep(100) - } - threads.add(thread) - thread.start() - } - threads.forEach { it.join() } - val finalMemory1 = getUsedMemory() - val individualThreadMemory = finalMemory1 - initialMemory1 - results["Individual Threads Memory"] = individualThreadMemory - - // Test 2: Dispatcher Memory Usage - val initialMemory2 = getUsedMemory() - val executor = - Executors.newFixedThreadPool( - 2, - ThreadFactory { r -> - Thread(r, "DispatcherThread-${System.nanoTime()}") - }, - ) - val dispatcher = executor.asCoroutineDispatcher() - - try { - runBlocking { - repeat(numberOfOperations) { i -> - launch(dispatcher) { - Thread.sleep(100) - } - } - } - } finally { - executor.shutdown() - } - val finalMemory2 = getUsedMemory() - val dispatcherMemory = finalMemory2 - initialMemory2 - results["Dispatcher Memory"] = dispatcherMemory - - // Test 3: OneSignal Dispatchers Memory Usage - val initialMemory3 = getUsedMemory() - runBlocking { - repeat(numberOfOperations) { i -> - OneSignalDispatchers.launchOnIO { - Thread.sleep(100) - } - } - } - val finalMemory3 = getUsedMemory() - val oneSignalMemory = finalMemory3 - initialMemory3 - results["OneSignal Dispatchers Memory"] = oneSignalMemory - - // Print results - println("\n=== Memory Usage Results ===") - results.forEach { (name, memory) -> - println("$name: ${memory}KB") - } - - // Dispatcher should use less memory than individual threads - dispatcherMemory shouldBeLessThan individualThreadMemory - oneSignalMemory shouldBeLessThan individualThreadMemory - } - - test("scalability comparison").config(enabled = runPerformanceTests) { - val testSizes = listOf(10, 50, 100) - val results = mutableMapOf>() - - testSizes.forEach { size -> - println("Testing with $size operations...") - - // Individual Threads - val individualTime = - measureTime { - val threads = mutableListOf() - repeat(size) { i -> - val thread = - Thread { - Thread.sleep(10) - } - threads.add(thread) - thread.start() - } - threads.forEach { it.join() } - } - results.getOrPut("Individual Threads") { mutableMapOf() }[size] = individualTime - - // Dispatcher - val dispatcherTime = - measureTime { - val executor = - Executors.newFixedThreadPool( - 2, - ThreadFactory { r -> - Thread(r, "DispatcherThread-${System.nanoTime()}") - }, - ) - val dispatcher = executor.asCoroutineDispatcher() - - try { - runBlocking { - repeat(size) { i -> - launch(dispatcher) { - Thread.sleep(10) - } - } - } - } finally { - executor.shutdown() - } - } - results.getOrPut("Dispatcher") { mutableMapOf() }[size] = dispatcherTime - - // OneSignal Dispatchers - val oneSignalTime = - measureTime { - runBlocking { - repeat(size) { i -> - OneSignalDispatchers.launchOnIO { - Thread.sleep(10) - } - } - } - } - results.getOrPut("OneSignal Dispatchers") { mutableMapOf() }[size] = oneSignalTime - } - - // Print scalability results - println("\n=== Scalability Results ===") - results.forEach { (name, times) -> - println("$name:") - times.forEach { (size, time) -> - println(" $size operations: ${time}ms") - } - } - - // Verify that dispatcher scales better than individual threads - testSizes.forEach { size -> - val individualTime = results["Individual Threads"]!![size]!! - val dispatcherTime = results["Dispatcher"]!![size]!! - val oneSignalTime = results["OneSignal Dispatchers"]!![size]!! - - dispatcherTime shouldBeLessThan individualTime - oneSignalTime shouldBeLessThan individualTime - } - } - - test("thread creation vs dispatcher creation performance").config(enabled = runPerformanceTests) { - val numberOfTests = 1000 - val results = mutableMapOf() - - // Test 1: Individual Thread Creation - val threadCreationTime = - measureTime { - repeat(numberOfTests) { i -> - Thread { - // Empty thread - }.start() - } - } - results["Thread Creation"] = threadCreationTime - - // Test 2: Dispatcher Creation - val dispatcherCreationTime = - measureTime { - repeat(numberOfTests) { i -> - val executor = - Executors.newFixedThreadPool( - 2, - ThreadFactory { r -> - Thread(r, "DispatcherThread-${System.nanoTime()}") - }, - ) - val dispatcher = executor.asCoroutineDispatcher() - executor.shutdown() - } - } - results["Dispatcher Creation"] = dispatcherCreationTime - - // Test 3: OneSignal Dispatchers (reuse existing) - val oneSignalTime = - measureTime { - repeat(numberOfTests) { i -> - OneSignalDispatchers.launchOnIO { - // Empty coroutine - } - } - } - results["OneSignal Dispatchers"] = oneSignalTime - - // Print results - println("\n=== Creation Performance Results ===") - results.forEach { (name, time) -> - println("$name: ${time}ms") - } - - // OneSignal dispatchers should be fastest (reusing existing pool) - oneSignalTime shouldBeLessThan threadCreationTime - oneSignalTime shouldBeLessThan dispatcherCreationTime - } - - test("resource cleanup comparison").config(enabled = runPerformanceTests) { - val numberOfOperations = 100 - val initialThreads = Thread.activeCount() - - // Test 1: Individual Threads (should create many threads) - repeat(numberOfOperations) { i -> - Thread { - Thread.sleep(50) - }.start() - } - Thread.sleep(200) // Wait for completion - val afterIndividualThreads = Thread.activeCount() - - // Test 2: Dispatcher (should reuse threads) - val executor = - Executors.newFixedThreadPool( - 2, - ThreadFactory { r -> - Thread(r, "DispatcherThread-${System.nanoTime()}") - }, - ) - val dispatcher = executor.asCoroutineDispatcher() - - try { - runBlocking { - repeat(numberOfOperations) { i -> - launch(dispatcher) { - Thread.sleep(50) - } - } - } - } finally { - executor.shutdown() - } - val afterDispatcher = Thread.activeCount() - - // Test 3: OneSignal Dispatchers (should reuse threads) - runBlocking { - repeat(numberOfOperations) { i -> - OneSignalDispatchers.launchOnIO { - Thread.sleep(50) - } - } - } - val afterOneSignal = Thread.activeCount() - - println("\n=== Resource Usage Results ===") - println("Initial threads: $initialThreads") - println("After individual threads: $afterIndividualThreads") - println("After dispatcher: $afterDispatcher") - println("After OneSignal dispatchers: $afterOneSignal") - - // Dispatcher should use fewer threads than individual threads - afterDispatcher shouldBeLessThan afterIndividualThreads - afterOneSignal shouldBeLessThan afterIndividualThreads - } - - test("concurrent access performance").config(enabled = runPerformanceTests) { - val numberOfConcurrentOperations = 50 - val results = mutableMapOf() - - // Test 1: Individual Threads with concurrent access - val individualTime = - measureTime { - val threads = mutableListOf() - repeat(numberOfConcurrentOperations) { i -> - val thread = - Thread { - Thread.sleep(20) - } - threads.add(thread) - thread.start() - } - threads.forEach { it.join() } - } - results["Individual Threads"] = individualTime - - // Test 2: Dispatcher with concurrent access - val dispatcherTime = - measureTime { - val executor = - Executors.newFixedThreadPool( - 2, - ThreadFactory { r -> - Thread(r, "DispatcherThread-${System.nanoTime()}") - }, - ) - val dispatcher = executor.asCoroutineDispatcher() - - try { - runBlocking { - repeat(numberOfConcurrentOperations) { i -> - launch(dispatcher) { - Thread.sleep(20) - } - } - } - } finally { - executor.shutdown() - } - } - results["Dispatcher"] = dispatcherTime - - // Test 3: OneSignal Dispatchers with concurrent access - val oneSignalTime = - measureTime { - runBlocking { - repeat(numberOfConcurrentOperations) { i -> - OneSignalDispatchers.launchOnIO { - Thread.sleep(20) - } - } - } - } - results["OneSignal Dispatchers"] = oneSignalTime - - // Print results - println("\n=== Concurrent Access Performance Results ===") - results.forEach { (name, time) -> - println("$name: ${time}ms") - } - - // Dispatcher should handle concurrent access better - dispatcherTime shouldBeLessThan individualTime - oneSignalTime shouldBeLessThan individualTime - } -}) - -private fun measureTime(block: () -> Unit): Long { - val startTime = System.currentTimeMillis() - block() - val endTime = System.currentTimeMillis() - return endTime - startTime -} - -private fun getUsedMemory(): Long { - val runtime = Runtime.getRuntime() - return (runtime.totalMemory() - runtime.freeMemory()) / 1024 // Convert to KB -} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/ThreadingPerformanceDemoTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/ThreadingPerformanceDemoTests.kt deleted file mode 100644 index b34d6f40c9..0000000000 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/ThreadingPerformanceDemoTests.kt +++ /dev/null @@ -1,236 +0,0 @@ -package com.onesignal.common.threading - -import com.onesignal.debug.LogLevel -import com.onesignal.debug.internal.logging.Logging -import io.kotest.core.spec.style.FunSpec -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.launch -import kotlinx.coroutines.newSingleThreadContext -import java.util.concurrent.Executors -import java.util.concurrent.ThreadFactory - -class ThreadingPerformanceDemoTests : FunSpec({ - - val runPerformanceTests = System.getenv("RUN_PERFORMANCE_TESTS") == "true" - - beforeAny { - Logging.logLevel = LogLevel.NONE - } - - test("demonstrate dispatcher vs individual threads performance").config(enabled = runPerformanceTests) { - val numberOfOperations = 50 - val results = mutableMapOf() - - println("\n=== Threading Performance Comparison ===") - println("Testing with $numberOfOperations operations...") - - // Test 1: Individual Thread Creation - val individualThreadTime = - measureTime { - repeat(numberOfOperations) { i -> - val context = newSingleThreadContext("IndividualThread-$i") - try { - CoroutineScope(context).launch { - Thread.sleep(10) // Simulate work - } - } finally { - // Note: newSingleThreadContext doesn't have close() method - // The context will be cleaned up when the scope is cancelled - } - } - } - results["Individual Threads"] = individualThreadTime - - // Test 2: Dispatcher with 2 threads - val dispatcherTime = - measureTime { - val executor = - Executors.newFixedThreadPool( - 2, - ThreadFactory { r -> - Thread(r, "DispatcherThread-${System.nanoTime()}") - }, - ) - val dispatcher = executor.asCoroutineDispatcher() - - try { - repeat(numberOfOperations) { i -> - CoroutineScope(dispatcher).launch { - Thread.sleep(10) // Simulate work - } - } - } finally { - executor.shutdown() - } - } - results["Dispatcher (2 threads)"] = dispatcherTime - - // Test 3: OneSignal Dispatchers (for comparison) - val oneSignalTime = - measureTime { - repeat(numberOfOperations) { i -> - OneSignalDispatchers.launchOnIO { - Thread.sleep(10) // Simulate work - } - } - } - results["OneSignal Dispatchers"] = oneSignalTime - - // Print results - println("\n=== Results ===") - results.forEach { (name, time) -> - println("$name: ${time}ms") - } - - // Calculate ratios - val individualTime = results["Individual Threads"]!! - val dispatcherTimeResult = results["Dispatcher (2 threads)"]!! - val oneSignalTimeResult = results["OneSignal Dispatchers"]!! - - println("\n=== Performance Ratios ===") - println("Individual Threads vs Dispatcher: ${individualTime.toDouble() / dispatcherTimeResult}x slower") - println("Individual Threads vs OneSignal: ${individualTime.toDouble() / oneSignalTimeResult}x slower") - println("Dispatcher vs OneSignal: ${dispatcherTimeResult.toDouble() / oneSignalTimeResult}x slower") - - println("\n=== Analysis ===") - if (individualTime > dispatcherTimeResult) { - println("✅ Dispatcher is ${individualTime.toDouble() / dispatcherTimeResult}x faster than individual threads") - } - if (individualTime > oneSignalTimeResult) { - println("✅ OneSignal Dispatchers are ${individualTime.toDouble() / oneSignalTimeResult}x faster than individual threads") - } - } - - test("demonstrate resource usage difference").config(enabled = runPerformanceTests) { - val initialThreadCount = Thread.activeCount() - - println("\n=== Resource Usage Comparison ===") - println("Initial thread count: $initialThreadCount") - - // Test individual thread creation - val individualContexts = mutableListOf() - repeat(50) { i -> - val context = newSingleThreadContext("ResourceTest-$i") - individualContexts.add(context) - } - val individualThreadCount = Thread.activeCount() - - println("After creating 50 individual thread contexts: $individualThreadCount (+${individualThreadCount - initialThreadCount})") - - // Test dispatcher usage - val executor = - Executors.newFixedThreadPool( - 2, - ThreadFactory { r -> - Thread(r, "ResourceDispatcher-${System.nanoTime()}") - }, - ) - val dispatcher = executor.asCoroutineDispatcher() - - repeat(50) { i -> - CoroutineScope(dispatcher).launch { - Thread.sleep(10) - } - } - val dispatcherThreadCount = Thread.activeCount() - - println("After using dispatcher with 50 operations: $dispatcherThreadCount (+${dispatcherThreadCount - initialThreadCount})") - - // Clean up - executor.shutdown() - Thread.sleep(100) // Allow cleanup - - val finalThreadCount = Thread.activeCount() - println("Final thread count after cleanup: $finalThreadCount") - - println("\n=== Resource Analysis ===") - val individualThreadsCreated = individualThreadCount - initialThreadCount - val dispatcherThreadsCreated = dispatcherThreadCount - initialThreadCount - - println("Individual threads created: $individualThreadsCreated") - println("Dispatcher threads created: $dispatcherThreadsCreated") - - if (dispatcherThreadsCreated < individualThreadsCreated) { - println("✅ Dispatcher uses ${individualThreadsCreated - dispatcherThreadsCreated} fewer threads") - } - } - - test("demonstrate scalability difference").config(enabled = runPerformanceTests) { - val operationCounts = listOf(10, 50, 100, 200) - val results = mutableMapOf>() - - println("\n=== Scalability Test ===") - println("Testing different operation counts...") - - operationCounts.forEach { count -> - // Individual threads - val individualTime = - measureTime { - val contexts = - (1..count).map { - newSingleThreadContext("ScaleTest-$it") - } - try { - contexts.forEach { context -> - CoroutineScope(context).launch { - Thread.sleep(5) - } - } - } finally { - // Note: newSingleThreadContext doesn't have close() method - // The contexts will be cleaned up when the scopes are cancelled - } - } - - // Dispatcher - val dispatcherTime = - measureTime { - val executor = - Executors.newFixedThreadPool( - 2, - ThreadFactory { r -> - Thread(r, "ScaleDispatcher-${System.nanoTime()}") - }, - ) - val dispatcher = executor.asCoroutineDispatcher() - - try { - repeat(count) { - CoroutineScope(dispatcher).launch { - Thread.sleep(5) - } - } - } finally { - executor.shutdown() - } - } - - results[count] = Pair(individualTime, dispatcherTime) - } - - println("\n=== Scalability Results ===") - println("Operations | Individual | Dispatcher | Ratio") - println("-----------|------------|------------|------") - - results.forEach { (count, times) -> - val ratio = if (times.second > 0) times.first.toDouble() / times.second else Double.POSITIVE_INFINITY - val ratioStr = if (ratio == Double.POSITIVE_INFINITY) "∞" else "%.2fx".format(ratio) - println("%-10d | %-10d | %-10d | %s".format(count, times.first, times.second, ratioStr)) - } - - println("\n=== Scalability Analysis ===") - results.forEach { (count, times) -> - if (times.first > times.second) { - val ratio = if (times.second > 0) times.first.toDouble() / times.second else Double.POSITIVE_INFINITY - println("✅ With $count operations: Dispatcher is ${if (ratio == Double.POSITIVE_INFINITY) "infinitely" else "${ratio}x"} faster") - } - } - } -}) - -private fun measureTime(block: () -> Unit): Long { - val startTime = System.currentTimeMillis() - block() - return System.currentTimeMillis() - startTime -} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/ApplicationServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/ApplicationServiceTests.kt index 81f0df9825..cd9f3d1712 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/ApplicationServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/ApplicationServiceTests.kt @@ -5,7 +5,7 @@ import android.content.Context import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.common.threading.WaiterWithValue -import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.internal.application.impl.ApplicationService import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging @@ -38,9 +38,12 @@ class ApplicationServiceTests : FunSpec({ test("start application service with activity shows entry state as closed") { // Given - val controller1 = Robolectric.buildActivity(Activity::class.java) - controller1.setup() // Moves Activity to RESUMED state - val activity = controller1.get() + val activity: Activity + + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity = controller.get() + } val applicationService = ApplicationService() // When @@ -53,12 +56,17 @@ class ApplicationServiceTests : FunSpec({ test("current activity is established when activity is started") { // Given - val controller1 = Robolectric.buildActivity(Activity::class.java) - controller1.setup() // Moves Activity to RESUMED state - val activity1 = controller1.get() - val controller2 = Robolectric.buildActivity(Activity::class.java) - controller2.setup() // Moves Activity to RESUMED state - val activity2 = controller2.get() + val activity1: Activity + val activity2: Activity + val context = ApplicationProvider.getApplicationContext() + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity1 = controller.get() + } + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity2 = controller.get() + } val applicationService = ApplicationService() @@ -76,12 +84,17 @@ class ApplicationServiceTests : FunSpec({ test("current activity is established when activity is stopped") { // Given - val controller1 = Robolectric.buildActivity(Activity::class.java) - controller1.setup() // Moves Activity to RESUMED state - val activity1 = controller1.get() - val controller2 = Robolectric.buildActivity(Activity::class.java) - controller2.setup() // Moves Activity to RESUMED state - val activity2 = controller2.get() + val activity1: Activity + val activity2: Activity + + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity1 = controller.get() + } + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity2 = controller.get() + } val applicationService = ApplicationService() @@ -99,12 +112,17 @@ class ApplicationServiceTests : FunSpec({ test("unfocus will occur when when all activities are stopped") { // Given - val controller1 = Robolectric.buildActivity(Activity::class.java) - controller1.setup() // Moves Activity to RESUMED state - val activity1 = controller1.get() - val controller2 = Robolectric.buildActivity(Activity::class.java) - controller2.setup() // Moves Activity to RESUMED state - val activity2 = controller2.get() + val activity1: Activity + val activity2: Activity + + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity1 = controller.get() + } + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity2 = controller.get() + } val mockApplicationLifecycleHandler = spyk() val applicationService = ApplicationService() @@ -126,12 +144,17 @@ class ApplicationServiceTests : FunSpec({ test("focus will occur when when the first activity is started") { // Given - val controller1 = Robolectric.buildActivity(Activity::class.java) - controller1.setup() // Moves Activity to RESUMED state - val activity1 = controller1.get() - val controller2 = Robolectric.buildActivity(Activity::class.java) - controller2.setup() // Moves Activity to RESUMED state - val activity2 = controller2.get() + val activity1: Activity + val activity2: Activity + + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity1 = controller.get() + } + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity2 = controller.get() + } val mockApplicationLifecycleHandler = spyk() val applicationService = ApplicationService() @@ -157,9 +180,12 @@ class ApplicationServiceTests : FunSpec({ test("focus will occur on subscribe when activity is already started") { // Given - val controller1 = Robolectric.buildActivity(Activity::class.java) - controller1.setup() // Moves Activity to RESUMED state - val activity = controller1.get() + val activity: Activity + + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity = controller.get() + } val applicationService = ApplicationService() val mockApplicationLifecycleHandler = spyk() @@ -185,16 +211,17 @@ class ApplicationServiceTests : FunSpec({ test("wait until system condition returns false if activity not started within 5 seconds") { // Given - val controller1 = Robolectric.buildActivity(Activity::class.java) - controller1.setup() // Moves Activity to RESUMED state - val activity = controller1.get() - + val activity: Activity + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity = controller.get() + } val applicationService = ApplicationService() val waiter = WaiterWithValue() // When - suspendifyOnIO { + suspendifyOnThread { val response = applicationService.waitUntilSystemConditionsAvailable() waiter.wake(response) } @@ -210,16 +237,17 @@ class ApplicationServiceTests : FunSpec({ test("wait until system condition returns true when an activity is started within 5 seconds") { // Given - val controller1 = Robolectric.buildActivity(Activity::class.java) - controller1.setup() // Moves Activity to RESUMED state - val activity = controller1.get() - + val activity: Activity + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity = controller.get() + } val applicationService = ApplicationService() val waiter = WaiterWithValue() // When - suspendifyOnIO { + suspendifyOnThread { val response = applicationService.waitUntilSystemConditionsAvailable() waiter.wake(response) } @@ -235,9 +263,12 @@ class ApplicationServiceTests : FunSpec({ test("wait until system condition returns true when there is no system condition") { // Given - val controller1 = Robolectric.buildActivity(Activity::class.java) - controller1.setup() // Moves Activity to RESUMED state - val activity = controller1.get() + val activity: Activity + + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity = controller.get() + } val applicationService = ApplicationService() // When 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 deleted file mode 100644 index 07fce3358c..0000000000 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt +++ /dev/null @@ -1,309 +0,0 @@ -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.debug.LogLevel -import com.onesignal.debug.internal.logging.Logging -import com.onesignal.internal.OneSignalImp -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout - -@RobolectricTest -class SDKInitSuspendTests : FunSpec({ - - beforeAny { - Logging.logLevel = LogLevel.NONE - } - - afterAny { - val context = getApplicationContext() - - // AGGRESSIVE CLEANUP: Clear ALL SharedPreferences to ensure complete isolation - val prefs = context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE) - prefs.edit().clear().commit() - - // Also clear any other potential SharedPreferences files - val otherPrefs = context.getSharedPreferences("com.onesignal", Context.MODE_PRIVATE) - otherPrefs.edit().clear().commit() - - // Wait longer to ensure cleanup is complete - Thread.sleep(50) - } - - // ===== INITIALIZATION TESTS ===== - - test("initWithContextSuspend with appId returns true") { - // Given - val context = getApplicationContext() - val os = OneSignalImp() - - runBlocking { - // When - val result = os.initWithContextSuspend(context, "testAppId") - - // Then - result shouldBe true - os.isInitialized shouldBe true - } - } - - test("initWithContextSuspend with null appId fails when configModel has no appId") { - // Given - val context = getApplicationContext() - - // COMPLETE STATE RESET: Clear ALL SharedPreferences and wait for completion - val prefs = context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE) - prefs.edit().clear().commit() - - // Clear any other potential SharedPreferences files - val otherPrefs = context.getSharedPreferences("com.onesignal", Context.MODE_PRIVATE) - otherPrefs.edit().clear().commit() - - // Clear any other potential preference stores that might exist - try { - val allPrefs = context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE) - allPrefs.edit().clear().commit() - } catch (e: Exception) { - // Ignore any errors during cleanup - } - - // Wait longer to ensure all cleanup operations are complete - Thread.sleep(100) - - // Verify cleanup worked - this should be empty - val verifyPrefs = context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE) - val allKeys = verifyPrefs.all - if (allKeys.isNotEmpty()) { - println("WARNING: SharedPreferences still contains keys after cleanup: $allKeys") - // Force clear again - verifyPrefs.edit().clear().commit() - Thread.sleep(50) - } - - // Create a completely fresh OneSignalImp instance for this test - val os = OneSignalImp() - - runBlocking { - // When - val result = os.initWithContextSuspend(context, null) - - // Debug output for CI/CD troubleshooting - println("DEBUG: initWithContextSuspend result = $result") - println("DEBUG: os.isInitialized = ${os.isInitialized}") - - // Additional debug: Check what's in SharedPreferences after the call - val debugPrefs = context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE) - val debugKeys = debugPrefs.all - println("DEBUG: SharedPreferences after initWithContextSuspend: $debugKeys") - - // Then - should return false because no appId is provided and configModel doesn't have an appId - result shouldBe false - os.isInitialized shouldBe false - } - } - - test("initWithContextSuspend is idempotent") { - // Given - val context = getApplicationContext() - val os = OneSignalImp() - - runBlocking { - // When - val result1 = os.initWithContextSuspend(context, "testAppId") - val result2 = os.initWithContextSuspend(context, "testAppId") - val result3 = os.initWithContextSuspend(context, "testAppId") - - // Then - result1 shouldBe true - result2 shouldBe true - result3 shouldBe true - os.isInitialized shouldBe true - } - } - - // ===== LOGIN TESTS ===== - - test("login suspend method works after initWithContextSuspend") { - // Given - val context = getApplicationContext() - val os = OneSignalImp() - val testExternalId = "testUser123" - - runBlocking { - // When - val initResult = os.initWithContextSuspend(context, "testAppId") - initResult shouldBe true - - // Login with timeout - demonstrates suspend method works correctly - try { - withTimeout(2000) { // 2 second timeout - os.login(testExternalId) - } - // If we get here, login completed successfully (unlikely in test env) - os.isInitialized shouldBe true - } catch (e: kotlinx.coroutines.TimeoutCancellationException) { - // Expected timeout due to operation queue processing in test environment - // This proves the suspend method is working correctly - os.isInitialized shouldBe true - println("Login suspend method works correctly - timed out as expected due to operation queue") - } - } - } - - // Note: Tests for null appId removed since appId is now non-nullable - - test("login suspend method with JWT token") { - // Given - val context = getApplicationContext() - val os = OneSignalImp() - val testExternalId = "testUser789" - val jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - - runBlocking { - // When - val initResult = os.initWithContextSuspend(context, "testAppId") - initResult shouldBe true - - try { - withTimeout(2000) { // 2 second timeout - os.login(testExternalId, jwtToken) - } - os.isInitialized shouldBe true - } catch (e: kotlinx.coroutines.TimeoutCancellationException) { - // Expected timeout due to operation queue processing - os.isInitialized shouldBe true - println("Login with JWT suspend method works correctly - timed out as expected due to operation queue") - } - } - } - - // ===== LOGOUT TESTS ===== - - test("logout suspend method works after initWithContextSuspend") { - // Given - val context = getApplicationContext() - val os = OneSignalImp() - - runBlocking { - // When - val initResult = os.initWithContextSuspend(context, "testAppId") - initResult shouldBe true - - // Logout with timeout - demonstrates suspend method works correctly - try { - withTimeout(2000) { // 2 second timeout - os.logout() - } - // If we get here, logout completed successfully (unlikely in test env) - os.isInitialized shouldBe true - } catch (e: kotlinx.coroutines.TimeoutCancellationException) { - // Expected timeout due to operation queue processing in test environment - // This proves the suspend method is working correctly - os.isInitialized shouldBe true - println("Logout suspend method works correctly - timed out as expected due to operation queue") - } - } - } - - // ===== INTEGRATION TESTS ===== - - test("multiple login calls work correctly") { - // Given - val context = getApplicationContext() - val os = OneSignalImp() - - runBlocking { - // When - val initResult = os.initWithContextSuspend(context, "testAppId") - initResult shouldBe true - - try { - withTimeout(3000) { // 3 second timeout for multiple operations - os.login("user1") - os.login("user2") - os.login("user3") - } - os.isInitialized shouldBe true - } catch (e: kotlinx.coroutines.TimeoutCancellationException) { - // Expected timeout due to operation queue processing - os.isInitialized shouldBe true - println("Multiple login calls suspend method works correctly - timed out as expected due to operation queue") - } - } - } - - test("login and logout sequence works correctly") { - // Given - val context = getApplicationContext() - val os = OneSignalImp() - - runBlocking { - // When - val initResult = os.initWithContextSuspend(context, "testAppId") - initResult shouldBe true - - try { - withTimeout(3000) { // 3 second timeout for sequence - os.login("user1") - os.logout() - os.login("user2") - } - os.isInitialized shouldBe true - } catch (e: kotlinx.coroutines.TimeoutCancellationException) { - // Expected timeout due to operation queue processing - os.isInitialized shouldBe true - println("Login/logout sequence suspend methods work correctly - timed out as expected due to operation queue") - } - } - } - - test("login should throw exception when initWithContext is never called") { - // Given - val oneSignalImp = OneSignalImp() - - // When/Then - should throw exception immediately - val exception = - shouldThrow { - oneSignalImp.login("testUser", null) - } - - // Should throw immediately because isInitialized is false - exception.message shouldBe "Must call 'initWithContext' before 'login'" - } - - test("loginSuspend should throw exception when initWithContext is never called") { - // Given - val oneSignalImp = OneSignalImp() - - // When/Then - should throw exception immediately - runBlocking { - val exception = - shouldThrow { - oneSignalImp.loginSuspend("testUser", null) - } - - // Should throw immediately because isInitialized is false - exception.message shouldBe "Must call 'initWithContext' before use" - } - } - - test("logoutSuspend should throw exception when initWithContext is never called") { - // Given - val oneSignalImp = OneSignalImp() - - // When/Then - should throw exception immediately - runBlocking { - val exception = - shouldThrow { - oneSignalImp.logoutSuspend() - } - - // Should throw immediately because isInitialized is false - exception.message shouldBe "Must call 'initWithContext' before use" - } - } -}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt deleted file mode 100644 index 318f6cb1c1..0000000000 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt +++ /dev/null @@ -1,457 +0,0 @@ -package com.onesignal.core.internal.application - -import android.content.Context -import android.content.ContextWrapper -import android.content.SharedPreferences -import androidx.test.core.app.ApplicationProvider.getApplicationContext -import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest -import com.onesignal.common.threading.CompletionAwaiter -import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID -import com.onesignal.debug.LogLevel -import com.onesignal.debug.internal.logging.Logging -import com.onesignal.internal.OneSignalImp -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.maps.shouldContain -import io.kotest.matchers.shouldBe -import io.kotest.matchers.shouldNotBe -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.runBlocking - -@RobolectricTest -class SDKInitTests : FunSpec({ - - beforeAny { - Logging.logLevel = LogLevel.NONE - - // Aggressive pre-test cleanup to avoid state leakage across tests - val context = getApplicationContext() - val prefs = context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE) - prefs.edit() - .clear() - .commit() - - val otherPrefs = context.getSharedPreferences("com.onesignal", Context.MODE_PRIVATE) - otherPrefs.edit() - .clear() - .commit() - - Thread.sleep(100) - } - - afterAny { - val context = getApplicationContext() - val prefs = context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE) - prefs.edit() - .clear() - .commit() - - // Also clear any other potential SharedPreferences files - val otherPrefs = context.getSharedPreferences("com.onesignal", Context.MODE_PRIVATE) - otherPrefs.edit() - .clear() - .commit() - - // Wait longer to ensure cleanup is complete - Thread.sleep(100) - - // Clear any in-memory state by initializing and logging out a fresh instance - try { - val os = OneSignalImp() - os.initWithContext(context, "appId") - os.logout() - Thread.sleep(100) - } catch (ignored: Exception) { - // ignore cleanup exceptions - } - } - - test("OneSignal accessors throw before calling initWithContext") { - val os = OneSignalImp() - - shouldThrow { - os.user - } - shouldThrow { - os.inAppMessages - } - shouldThrow { - os.session - } - shouldThrow { - os.notifications - } - shouldThrow { - os.location - } - } - - test("initWithContext with no appId succeeds when configModel has appId") { - // Given - // block SharedPreference before calling init - val trigger = CompletionAwaiter("Test") - val context = getApplicationContext() - val blockingPrefContext = BlockingPrefsContext(context, trigger, 2000) - val os = OneSignalImp() - var initSuccess = true - - // Clear any existing appId from previous tests by clearing SharedPreferences - val prefs = context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE) - prefs.edit() - .clear() - .commit() - - // Set up a legacy appId in SharedPreferences to simulate a previous test scenario - // This simulates the case where a previous test has set an appId that can be resolved - prefs.edit() - .putString(PREFS_LEGACY_APP_ID, "testAppId") // Set legacy appId - .commit() - - // When - val accessorThread = - Thread { - // this will block until after SharedPreferences is released - runBlocking { - initSuccess = os.initWithContext(blockingPrefContext) - } - } - - accessorThread.start() - accessorThread.join(500) - - accessorThread.isAlive shouldBe true - - // release SharedPreferences - trigger.complete() - - accessorThread.join(500) - accessorThread.isAlive shouldBe false - - // Should return true because configModel already has an appId from previous tests - initSuccess shouldBe true - os.isInitialized shouldBe true - } - - test("initWithContext with appId does not block") { - // Given - // block SharedPreference before calling init - val trigger = CompletionAwaiter("Test") - val context = getApplicationContext() - val blockingPrefContext = BlockingPrefsContext(context, trigger, 1000) - val os = OneSignalImp() - - // When - val accessorThread = - Thread { - os.initWithContext(blockingPrefContext, "appId") - } - - accessorThread.start() - accessorThread.join(500) - - // Then - // should complete even SharedPreferences is unavailable - accessorThread.isAlive shouldBe false - os.isInitialized shouldBe true - } - - test("accessors will be blocked if call too early after initWithContext with appId") { - // Given - // block SharedPreference before calling init - val trigger = CompletionAwaiter("Test") - val context = getApplicationContext() - val blockingPrefContext = BlockingPrefsContext(context, trigger, 2000) - val os = OneSignalImp() - - val accessorThread = - Thread { - os.initWithContext(blockingPrefContext, "appId") - os.user // This should block until either trigger is released or timed out - } - - accessorThread.start() - accessorThread.join(500) - - accessorThread.isAlive shouldBe true - - // release the lock on SharedPreferences - trigger.complete() - - accessorThread.join(1000) - accessorThread.isAlive shouldBe false - os.isInitialized shouldBe true - } - - test("ensure adding tags right after initWithContext with appId is successful") { - // Given - val context = getApplicationContext() - val os = OneSignalImp() - val tagKey = "tagKey" - val tagValue = "tagValue" - val testTags = mapOf(tagKey to tagValue) - - // When - os.initWithContext(context, "appId") - os.user.addTags(testTags) - - // Then - val tags = os.user.getTags() - tags shouldContain (tagKey to tagValue) - } - - test("ensure login called right after initWithContext can set externalId correctly") { - // Given - // block SharedPreference before calling init - val trigger = CompletionAwaiter("Test") - val context = getApplicationContext() - val blockingPrefContext = BlockingPrefsContext(context, trigger, 2000) - val os = OneSignalImp() - val externalId = "testUser" - - val accessorThread = - Thread { - os.initWithContext(blockingPrefContext, "appId") - os.login(externalId) - - // Wait for background login operation to complete with polling - var attempts = 0 - while (os.user.externalId != externalId && attempts < 50) { - Thread.sleep(20) - attempts++ - } - } - - accessorThread.start() - accessorThread.join(500) - - os.isInitialized shouldBe true - accessorThread.isAlive shouldBe true - - // release the lock on SharedPreferences - trigger.complete() - - accessorThread.join(500) - accessorThread.isAlive shouldBe false - os.user.externalId shouldBe externalId - } - - test("a push subscription should be created right after initWithContext") { - // Given - val context = getApplicationContext() - val os = OneSignalImp() - os.initWithContext(context, "appId") - - // When - val pushSub = os.user.pushSubscription - - // Then - pushSub shouldNotBe null - pushSub.token shouldNotBe null - } - - test("login changes externalId from initial state after init") { - // Given - val context = getApplicationContext() - val os = OneSignalImp() - val testExternalId = "uniqueTestUser_${System.currentTimeMillis()}" // Use unique ID to avoid conflicts - - // When - os.initWithContext(context, "appId") - val initialExternalId = os.user.externalId - os.login(testExternalId) - - // Wait for background login operation to complete with polling - var attempts = 0 - while (os.user.externalId != testExternalId && attempts < 50) { - Thread.sleep(20) - attempts++ - } - - val finalExternalId = os.user.externalId - - // Then - Verify the complete login flow - // 1. Login should set the external ID to our test value - finalExternalId shouldBe testExternalId - - // 2. Login should change the external ID (regardless of initial state) - // This makes the test resilient to state contamination while still testing the flow - finalExternalId shouldNotBe initialExternalId - - // 3. If we're in a clean state, initial should be empty (but don't fail if not) - // This documents the expected behavior without making the test brittle - if (initialExternalId.isEmpty()) { - // Clean state detected - this is the ideal scenario - println("✅ Clean state: initial externalId was empty as expected") - } else { - // State contamination detected - log it but don't fail - println("⚠️ State contamination: initial externalId was '$initialExternalId' (expected empty)") - } - - // Clean up after ourselves to avoid polluting subsequent tests - os.logout() - - // Wait for logout to complete with polling - var logoutAttempts = 0 - while (os.user.externalId.isNotEmpty() && logoutAttempts < 50) { - Thread.sleep(20) - logoutAttempts++ - } - } - - test("accessor instances after multiple initWithContext calls are consistent") { - // Given - val context = getApplicationContext() - val os = OneSignalImp() - - // When - os.initWithContext(context, "appId") - - // Wait for initialization to complete before accessing user - var attempts = 0 - while (!os.isInitialized && attempts < 100) { - Thread.sleep(20) - attempts++ - } - os.isInitialized shouldBe true - - // Give additional time for coroutines to settle, especially in CI/CD - Thread.sleep(50) - - val oldUser = os.user - - // Second init from some internal class - os.initWithContext(context) - - // Wait for second initialization to complete - attempts = 0 - while (!os.isInitialized && attempts < 100) { - Thread.sleep(20) - attempts++ - } - os.isInitialized shouldBe true - - // Give additional time for coroutines to settle after second init - Thread.sleep(50) - - val newUser = os.user - - // Then - oldUser shouldBe newUser - } - - test("integration: full user workflow after initialization") { - val context = getApplicationContext() - val os = OneSignalImp() - val testExternalId = "test-user" - val tags = mapOf("test" to "integration", "version" to "1.0") - - os.initWithContext(context, "appId") - - // Test user workflow - // init - val initialExternalId = os.user.externalId - - // Handle state contamination gracefully - if externalId is not empty, logout first - if (initialExternalId.isNotEmpty()) { - println("⚠️ State contamination detected: initial externalId was '$initialExternalId' (expected empty)") - os.logout() - - // Wait for logout to complete with polling - var cleanupAttempts = 0 - while (os.user.externalId.isNotEmpty() && cleanupAttempts < 50) { - Thread.sleep(20) - cleanupAttempts++ - } - - val cleanedExternalId = os.user.externalId - cleanedExternalId shouldBe "" - } else { - initialExternalId shouldBe "" - } - - // login - os.login(testExternalId) - - // Wait for background login operation to complete with polling (CI-safe) - run { - var attempts = 0 - val maxAttempts = 200 // 4 seconds total at 20ms intervals - while (os.user.externalId != testExternalId && attempts < maxAttempts) { - Thread.sleep(20) - attempts++ - } - } - - os.user.externalId shouldBe testExternalId - - // addTags and getTags - os.user.addTags(tags) - val retrievedTags = os.user.getTags() - retrievedTags shouldContain ("test" to "integration") - retrievedTags shouldContain ("version" to "1.0") - - // logout - os.logout() - - // Wait for background logout operation to complete with polling - var attempts = 0 - while (os.user.externalId.isNotEmpty() && attempts < 50) { - Thread.sleep(20) - attempts++ - } - - os.user.externalId shouldBe "" - } - - test("login should throw exception when initWithContext is never called") { - // Given - val oneSignalImp = OneSignalImp() - - // When/Then - should throw exception immediately - val exception = - shouldThrow { - oneSignalImp.login("testUser", null) - } - - // Should throw immediately because isInitialized is false - exception.message shouldBe "Must call 'initWithContext' before 'login'" - } - - test("logout should throw exception when initWithContext is never called") { - // Given - val oneSignalImp = OneSignalImp() - - // When/Then - should throw exception immediately - val exception = - shouldThrow { - oneSignalImp.logout() - } - - // Should throw immediately because isInitialized is false - exception.message shouldBe "Must call 'initWithContext' before 'logout'" - } -}) - -/** - * Simulate a context awaiting for a shared preference until the trigger is signaled - */ -class BlockingPrefsContext( - context: Context, - private val unblockTrigger: CompletionAwaiter, - private val timeoutInMillis: Long, -) : ContextWrapper(context) { - override fun getSharedPreferences( - name: String, - mode: Int, - ): SharedPreferences { - try { - unblockTrigger.await(timeoutInMillis) - } catch (e: InterruptedException) { - throw e - } catch (e: TimeoutCancellationException) { - throw e - } - - return super.getSharedPreferences(name, mode) - } -} 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 5b514c21bc..4c5d5dc9ea 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.common.threading.OSPrimaryCoroutineScope import com.onesignal.common.threading.Waiter import com.onesignal.common.threading.WaiterWithValue import com.onesignal.core.internal.operations.impl.OperationModelStore @@ -15,8 +16,6 @@ import com.onesignal.mocks.MockPreferencesService import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getNewRecordState import com.onesignal.user.internal.operations.LoginUserOperation import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.ints.shouldBeGreaterThan -import io.kotest.matchers.ints.shouldBeLessThan import io.kotest.matchers.shouldBe import io.mockk.CapturingSlot import io.mockk.coEvery @@ -33,6 +32,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.yield import org.json.JSONArray import java.util.UUID @@ -132,15 +132,8 @@ class OperationRepoTests : FunSpec({ // Then // insertion from the main thread is done without blocking mainThread.join(500) - mainThread.state shouldBe Thread.State.TERMINATED - - // Wait for the async enqueue to complete (give it more time) - var attempts = 0 - while (operationRepo.queue.size == 0 && attempts < 50) { - Thread.sleep(10) - attempts++ - } operationRepo.queue.size shouldBe 1 + mainThread.state shouldBe Thread.State.TERMINATED // after loading is completed, the cached operation should be at the beginning of the queue backgroundThread.join() @@ -165,13 +158,7 @@ class OperationRepoTests : FunSpec({ // When operationRepo.start() operationRepo.enqueue(MyOperation()) - - // Wait for the async enqueue to complete - var attempts = 0 - while (!operationRepo.containsInstanceOf() && attempts < 50) { - Thread.sleep(10) - attempts++ - } + OSPrimaryCoroutineScope.waitForIdle() // Then operationRepo.containsInstanceOf() shouldBe true @@ -276,19 +263,19 @@ class OperationRepoTests : FunSpec({ // When opRepo.start() opRepo.enqueue(mockOperation()) - Thread.sleep(200) // Give time for the operation to be processed and retry delay to be set + OSPrimaryCoroutineScope.waitForIdle() val response1 = - withTimeoutOrNull(500) { + withTimeoutOrNull(999) { opRepo.enqueueAndWait(mockOperation()) } val response2 = - withTimeoutOrNull(2000) { + withTimeoutOrNull(100) { opRepo.enqueueAndWait(mockOperation()) } // Then - response1 shouldBe null // Should timeout due to 1s retry delay - response2 shouldBe true // Should succeed after retry delay expires + response1 shouldBe null + response2 shouldBe true } test("enqueue operation executes and is removed when executed after fail") { @@ -362,39 +349,27 @@ class OperationRepoTests : FunSpec({ val waiter = Waiter() every { mocks.operationModelStore.remove(any()) } answers {} andThenAnswer { waiter.wake() } - val operation1 = mockOperation("operationId1", groupComparisonType = GroupComparisonType.CREATE, createComparisonKey = "create-key") - val operation2 = mockOperation("operationId2", groupComparisonType = GroupComparisonType.CREATE, createComparisonKey = "create-key") + val operation1 = mockOperation("operationId1", groupComparisonType = GroupComparisonType.CREATE) + val operation2 = mockOperation("operationId2") // When - mocks.operationRepo.start() - - // Enqueue operations in sequence to ensure proper grouping mocks.operationRepo.enqueue(operation1) mocks.operationRepo.enqueue(operation2) + mocks.operationRepo.start() waiter.waitForWake() // Then - // Verify operations were added (order may vary due to threading) - coVerify { + coVerifyOrder { mocks.operationModelStore.add(operation1) mocks.operationModelStore.add(operation2) - } - - // Verify they were executed as a group (this is the key functionality) - coVerify { mocks.executor.execute( withArg { it.count() shouldBe 2 - // Operations should be grouped together, order within group may vary due to threading - it.contains(operation1) shouldBe true - it.contains(operation2) shouldBe true + it[0] shouldBe operation1 + it[1] shouldBe operation2 }, ) - } - - // Verify cleanup - coVerify { mocks.operationModelStore.remove("operationId1") mocks.operationModelStore.remove("operationId2") } @@ -410,9 +385,9 @@ class OperationRepoTests : FunSpec({ val operation2 = mockOperation("operationId2", groupComparisonType = GroupComparisonType.CREATE) // When - mocks.operationRepo.start() mocks.operationRepo.enqueue(operation1) mocks.operationRepo.enqueue(operation2) + mocks.operationRepo.start() waiter.waitForWake() @@ -452,16 +427,10 @@ class OperationRepoTests : FunSpec({ waiter.waitForWake() - // Then - Verify critical execution order (CI/CD friendly) - // First verify all operations happened - coVerify(exactly = 1) { mocks.operationModelStore.add(operation1) } - coVerify(exactly = 1) { mocks.operationModelStore.add(operation2) } - coVerify(exactly = 1) { operation2.translateIds(mapOf("id1" to "id2")) } - coVerify(exactly = 1) { mocks.operationModelStore.remove("operationId1") } - coVerify(exactly = 1) { mocks.operationModelStore.remove("operationId2") } - - // Then verify the critical execution order + // Then coVerifyOrder { + mocks.operationModelStore.add(operation1) + mocks.operationModelStore.add(operation2) mocks.executor.execute( withArg { it.count() shouldBe 1 @@ -469,12 +438,14 @@ class OperationRepoTests : FunSpec({ }, ) operation2.translateIds(mapOf("id1" to "id2")) + mocks.operationModelStore.remove("operationId1") mocks.executor.execute( withArg { it.count() shouldBe 1 it[0] shouldBe operation2 }, ) + mocks.operationModelStore.remove("operationId2") } } @@ -571,14 +542,14 @@ class OperationRepoTests : FunSpec({ test("starting OperationModelStore should be processed, following normal delay rules") { // Given val mocks = Mocks() - mocks.configModelStore.model.opRepoExecutionInterval = 200 + mocks.configModelStore.model.opRepoExecutionInterval = 100 every { mocks.operationModelStore.list() } returns listOf(mockOperation()) val executeOperationsCall = mockExecuteOperations(mocks.operationRepo) // When mocks.operationRepo.start() val immediateResult = - withTimeoutOrNull(200) { + withTimeoutOrNull(100) { executeOperationsCall.waitForWake() } val delayedResult = @@ -586,9 +557,9 @@ class OperationRepoTests : FunSpec({ executeOperationsCall.waitForWake() } - // Then - with parallel execution, timing may vary, so we just verify the operation eventually executes - val result = immediateResult ?: delayedResult - result shouldBe true + // Then + immediateResult shouldBe null + delayedResult shouldBe true } test("ensure results from executeOperations are added to beginning of the queue") { @@ -632,51 +603,53 @@ class OperationRepoTests : FunSpec({ val mocks = Mocks() mocks.configModelStore.model.opRepoPostCreateDelay = 100 val operation1 = mockOperation(groupComparisonType = GroupComparisonType.NONE) - operation1.id = "local-id1" - val operation2 = mockOperation(groupComparisonType = GroupComparisonType.NONE, applyToRecordId = "local-id1") + val operation2 = mockOperation(groupComparisonType = GroupComparisonType.NONE, applyToRecordId = "id2") val operation3 = mockOperation(groupComparisonType = GroupComparisonType.NONE) - coEvery { mocks.executor.execute(listOf(operation1)) } returns ExecutionResponse(ExecutionResult.SUCCESS, mapOf("local-id1" to "id2")) - coEvery { + + // When + mocks.operationRepo.start() + mocks.operationRepo.enqueue(operation1) + val job = launch { mocks.operationRepo.enqueueAndWait(operation2) }.also { yield() } + mocks.operationRepo.enqueueAndWait(operation3) + job.join() + + // Then + coVerifyOrder { + mocks.executor.execute(listOf(operation1)) + operation2.translateIds(mapOf("local-id1" to "id2")) mocks.executor.execute(listOf(operation2)) - } returns ExecutionResponse(ExecutionResult.SUCCESS) - coEvery { mocks.executor.execute(listOf(operation3)) - } returns ExecutionResponse(ExecutionResult.SUCCESS) + } + } + + // This tests the same logic as above, but makes sure the delay also + // applies to grouping operations. + test("execution of an operation with translation IDs delays follow up operations, including grouping") { + // Given + val mocks = Mocks() + mocks.configModelStore.model.opRepoPostCreateDelay = 100 + val operation1 = mockOperation(groupComparisonType = GroupComparisonType.NONE) + val operation2 = mockOperation(groupComparisonType = GroupComparisonType.CREATE) + val operation3 = mockOperation(groupComparisonType = GroupComparisonType.CREATE, applyToRecordId = "id2") + coEvery { + mocks.executor.execute(listOf(operation1)) + } returns ExecutionResponse(ExecutionResult.SUCCESS, mapOf("local-id1" to "id2")) // When mocks.operationRepo.start() mocks.operationRepo.enqueue(operation1) mocks.operationRepo.enqueue(operation2) + OSPrimaryCoroutineScope.waitForIdle() mocks.operationRepo.enqueueAndWait(operation3) - // Then - Verify critical operations happened, but be flexible about exact order for CI/CD - coVerify(exactly = 1) { - mocks.executor.execute( - withArg { - // ensure operation1 executed at least once - it.any { op -> op === operation1 } shouldBe true - }, - ) - } - coVerify(exactly = 1) { operation2.translateIds(mapOf("local-id1" to "id2")) } - coVerify(exactly = 1) { - mocks.executor.execute( - withArg { - // ensure operation2 executed at least once - it.any { op -> op === operation2 } shouldBe true - }, - ) - } - coVerify(exactly = 1) { - mocks.executor.execute( - withArg { - // ensure operation3 executed at least once - it.any { op -> op === operation3 } shouldBe true - }, - ) + // Then + coVerifyOrder { + mocks.executor.execute(listOf(operation1)) + operation2.translateIds(mapOf("local-id1" to "id2")) + mocks.executor.execute(listOf(operation2, operation3)) } } @@ -750,13 +723,7 @@ class OperationRepoTests : FunSpec({ val mocks = Mocks() val op = mockOperation() mocks.operationRepo.enqueue(op) - - // Wait for the async enqueue to complete - var attempts = 0 - while (mocks.operationRepo.queue.size == 0 && attempts < 50) { - Thread.sleep(10) - attempts++ - } + OSPrimaryCoroutineScope.waitForIdle() // When mocks.operationRepo.loadSavedOperations() @@ -797,7 +764,7 @@ class OperationRepoTests : FunSpec({ // When opRepo.start() opRepo.enqueue(mockOperation()) - Thread.sleep(100) // Give time for the operation to be processed and retry delay to be set + OSPrimaryCoroutineScope.waitForIdle() val response1 = withTimeoutOrNull(999) { opRepo.enqueueAndWait(mockOperation()) @@ -814,122 +781,6 @@ class OperationRepoTests : FunSpec({ response2 shouldBe true opRepo.forceExecuteOperations() } - - // This test verifies the critical execution order when translation IDs and grouping work together - // It ensures that operations requiring translation wait for translation mappings before being grouped - test("translation IDs are applied before operations are grouped with correct execution order") { - // Given - val mocks = Mocks() - mocks.configModelStore.model.opRepoPostCreateDelay = 100 - - // Track execution order using a list - val executionOrder = mutableListOf() - - // Create operations for testing translation + grouping interaction - val translationSource = mockOperation("translation-source", groupComparisonType = GroupComparisonType.NONE) - val groupableOp1 = mockOperation("groupable-1", groupComparisonType = GroupComparisonType.CREATE, createComparisonKey = "test-group", applyToRecordId = "target-id") - val groupableOp2 = mockOperation("groupable-2", groupComparisonType = GroupComparisonType.CREATE, createComparisonKey = "test-group", applyToRecordId = "different-id") - - // Mock the translateIds call to track when translation happens - every { groupableOp1.translateIds(any()) } answers { - executionOrder.add("translate-groupable-1") - Unit - } - - // Mock groupableOp2 to ensure it doesn't get translated - every { groupableOp2.translateIds(any()) } answers { - executionOrder.add("translate-groupable-2-unexpected") - Unit - } - - // Mock all execution calls and track them - coEvery { - mocks.executor.execute(any()) - } answers { - val operations = firstArg>() - - // Handle translation source (single operation that generates mappings) - if (operations.size == 1 && operations[0].id == translationSource.id) { - executionOrder.add("execute-translation-source") - return@answers ExecutionResponse(ExecutionResult.SUCCESS, mapOf("source-local-id" to "target-id")) - } - - // Handle grouped operations (both operations together) - if (operations.size == 2 && operations.any { it.id == groupableOp1.id } && operations.any { it.id == groupableOp2.id }) { - executionOrder.add("execute-grouped-operations") - return@answers ExecutionResponse(ExecutionResult.SUCCESS) - } - - // Handle any other cases - executionOrder.add("execute-other-${operations.size}") - ExecutionResponse(ExecutionResult.SUCCESS) - } - - // When - mocks.operationRepo.start() - - // Enqueue operations in a way that tests the critical scenario: - // 1. Translation source generates mappings - // 2. Operations needing translation wait for those mappings - // 3. After translation, operations are grouped and executed together - mocks.operationRepo.enqueue(translationSource) - mocks.operationRepo.enqueue(groupableOp1) // This needs translation - mocks.operationRepo.enqueueAndWait(groupableOp2) // This doesn't need translation but should be grouped - - // Wait for all critical async operations to complete - // We need: execute-translation-source, translate-groupable-1, execute-grouped-operations - var attempts = 0 - val maxAttempts = 200 // Increased timeout for CI/CD environments (200 * 20ms = 4 seconds) - while (attempts < maxAttempts) { - val hasTranslationSource = executionOrder.contains("execute-translation-source") - val hasTranslation = executionOrder.contains("translate-groupable-1") - val hasGroupedExecution = executionOrder.contains("execute-grouped-operations") - - if (hasTranslationSource && hasTranslation && hasGroupedExecution) { - break // All critical events have occurred - } - - Thread.sleep(20) - attempts++ - } - - // Then verify the critical execution order - executionOrder.size shouldBeGreaterThan 2 // At minimum: Translation source + translation + grouped execution (>= 3) - - // Verify all critical events occurred (errors will show actual executionOrder contents) - executionOrder.contains("execute-translation-source") shouldBe true - executionOrder.contains("translate-groupable-1") shouldBe true - executionOrder.contains("execute-grouped-operations") shouldBe true - - // Verify the exact execution order is strictly maintained: - // Expected order: [execute-translation-source, ..., translate-groupable-1, ..., execute-grouped-operations] - - val translationSourceIndex = executionOrder.indexOf("execute-translation-source") - val translationIndex = executionOrder.indexOf("translate-groupable-1") - val groupedExecutionIndex = executionOrder.indexOf("execute-grouped-operations") - - // 1. Translation source must execute first to generate mappings - translationSourceIndex shouldBe 0 - - // 2. Translation must happen after translation source but before grouped execution - translationIndex shouldBeGreaterThan translationSourceIndex - translationIndex shouldBeLessThan groupedExecutionIndex - - // 3. Grouped execution must be last (after translation completes) - groupedExecutionIndex shouldBe executionOrder.size - 1 - - // Final order verification: all three critical events in correct sequence - // translationSourceIndex (0) < translationIndex < groupedExecutionIndex (last) - translationSourceIndex shouldBeLessThan translationIndex - translationIndex shouldBeLessThan groupedExecutionIndex - - // Additional verifications to ensure the test is comprehensive - coVerify(exactly = 1) { mocks.executor.execute(listOf(translationSource)) } - coVerify(exactly = 1) { groupableOp1.translateIds(mapOf("source-local-id" to "target-id")) } - - // Verify that the grouped execution happened with both operations - // We can't easily verify the exact list content with MockK, but we verified it in the execution order tracking - } }) { companion object { private fun mockOperation( 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 deleted file mode 100644 index 6f0eb86ef6..0000000000 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.onesignal.core.internal.permissions - -import android.app.Activity -import com.onesignal.OneSignal -import com.onesignal.core.internal.permissions.impl.RequestPermissionService -import com.onesignal.core.internal.preferences.IPreferencesService -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.unmockkAll -import io.mockk.verify -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking - -class PermissionsViewModelTests : FunSpec({ - val permissionType = "location" - val androidPermission = "android.permission.ACCESS_FINE_LOCATION" - val mockRequestService = mockk(relaxed = true) - val mockPrefService = mockk(relaxed = true) - - beforeTest { - mockkObject(OneSignal) - } - - afterTest { - unmockkAll() - } - - test("initialize sets permissionRequestType and returns true") { - val viewModel = PermissionsViewModel() - val activity = mockk(relaxed = true) - - // Mock the services that will be accessed via lazy initialization - coEvery { OneSignal.initWithContext(any()) } returns true - every { OneSignal.getService() } returns mockRequestService - every { OneSignal.getService() } returns mockPrefService - - runBlocking { - val result = viewModel.initialize(activity, permissionType, androidPermission) - result shouldBe true - } - viewModel.permissionRequestType shouldBe permissionType - } - - test("initialize returns false when OneSignal init fails") { - val viewModel = PermissionsViewModel() - val activity = mockk(relaxed = true) - coEvery { OneSignal.initWithContext(activity) } returns false - - runBlocking { - val result = viewModel.initialize(activity, permissionType, androidPermission) - result shouldBe false - } - runBlocking { - viewModel.shouldFinish.first() shouldBe true - } - } - - test("shouldRequestPermission sets waiting to true") { - val viewModel = PermissionsViewModel() - - val result = viewModel.shouldRequestPermission() - - result shouldBe true - runBlocking { - viewModel.waiting.first() shouldBe true - } - } - - test("shouldRequestPermission returns false when already waiting") { - val viewModel = PermissionsViewModel() - viewModel.shouldRequestPermission() // First call sets waiting to true - - val result = viewModel.shouldRequestPermission() // Second call should return false - - result shouldBe false - } - - test("shouldRequestPermission prevents duplicate requests") { - val viewModel = PermissionsViewModel() - - // First call should return true and set waiting to true - val firstResult = viewModel.shouldRequestPermission() - firstResult shouldBe true - runBlocking { - viewModel.waiting.first() shouldBe true - } - - // Second call should return false (already waiting) - val secondResult = viewModel.shouldRequestPermission() - secondResult shouldBe false - } - - test("recordRationaleState sets the rationale state") { - val viewModel = PermissionsViewModel() - - // Mock the service - every { OneSignal.getService() } returns mockRequestService - - viewModel.recordRationaleState(true) - - verify { mockRequestService.shouldShowRequestPermissionRationaleBeforeRequest = true } - } - - test("resetWaitingState resets waiting flag to false") { - val viewModel = PermissionsViewModel() - - // First set waiting to true - viewModel.shouldRequestPermission() - runBlocking { - viewModel.waiting.first() shouldBe true - } - - // Reset the waiting state (simulating activity pause) - viewModel.resetWaitingState() - - // Verify waiting is now false - runBlocking { - viewModel.waiting.first() shouldBe false - } - } - - test("resetWaitingState allows permission request after reset") { - val viewModel = PermissionsViewModel() - - // First request should succeed - val firstResult = viewModel.shouldRequestPermission() - firstResult shouldBe true - - // Reset the waiting state (simulating activity pause) - viewModel.resetWaitingState() - - // Second request should now succeed again - val secondResult = viewModel.shouldRequestPermission() - secondResult shouldBe true - } - - test("resetWaitingState simulates activity pause scenario") { - val viewModel = PermissionsViewModel() - - // Simulate: User sees permission dialog - val firstResult = viewModel.shouldRequestPermission() - firstResult shouldBe true - runBlocking { - viewModel.waiting.first() shouldBe true - } - - // Simulate: Another activity comes on top (phone call, notification, etc.) - // Activity's onPause() calls resetWaitingState() - viewModel.resetWaitingState() - runBlocking { - viewModel.waiting.first() shouldBe false - } - - // Simulate: User returns to app, permission dialog can be shown again - val secondResult = viewModel.shouldRequestPermission() - secondResult shouldBe true - runBlocking { - viewModel.waiting.first() shouldBe true - } - } -}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt index a5d254cce6..140b9400ed 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt @@ -42,8 +42,8 @@ class StartupServiceTests : FunSpec({ test("bootstrap will call all IBootstrapService dependencies successfully") { // Given - val mockBootstrapService1 = mockk(relaxed = true) - val mockBootstrapService2 = mockk(relaxed = true) + val mockBootstrapService1 = spyk() + val mockBootstrapService2 = spyk() val startupService = StartupService(setupServiceProvider(listOf(mockBootstrapService1, mockBootstrapService2), listOf())) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpTests.kt index e5e49f1ec0..891139a411 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpTests.kt @@ -39,222 +39,51 @@ class OneSignalImpTests : FunSpec({ exception.message shouldBe "Must call 'initWithContext' before 'logout'" } - // Comprehensive tests for deprecated properties that should work before and after initialization - context("consentRequired property") { + // consentRequired probably should have thrown like the other OneSignal methods in 5.0.0, + // but we can't make a breaking change to an existing API. + context("consentRequired") { context("before initWithContext") { - test("get returns false by default") { - // Given - val os = OneSignalImp() - - // When & Then - os.consentRequired shouldBe false - } - - test("set and get works correctly") { + test("set should not throw") { // Given val os = OneSignalImp() - - // When - os.consentRequired = true - - // Then - os.consentRequired shouldBe true - // When os.consentRequired = false - + os.consentRequired = true // Then - os.consentRequired shouldBe false + // Test fails if the above throws } - - test("set should not throw") { + test("get should not throw") { // Given val os = OneSignalImp() - - // When & Then - should not throw - os.consentRequired = false - os.consentRequired = true + // When + println(os.consentRequired) + // Then + // Test fails if the above throws } } } - context("consentGiven property") { + // consentGiven probably should have thrown like the other OneSignal methods in 5.0.0, + // but we can't make a breaking change to an existing API. + context("consentGiven") { context("before initWithContext") { - test("get returns false by default") { - // Given - val os = OneSignalImp() - - // When & Then - os.consentGiven shouldBe false - } - - test("set and get works correctly") { + test("set should not throw") { // Given val os = OneSignalImp() - // When os.consentGiven = true - - // Then - os.consentGiven shouldBe true - - // When os.consentGiven = false - // Then - os.consentGiven shouldBe false - } - - test("set should not throw") { - // Given - val os = OneSignalImp() - - // When & Then - should not throw - os.consentGiven = true - os.consentGiven = false - } - } - } - - context("disableGMSMissingPrompt property") { - context("before initWithContext") { - test("get returns false by default") { - // Given - val os = OneSignalImp() - - // When & Then - os.disableGMSMissingPrompt shouldBe false + // Test fails if the above throws } - - test("set and get works correctly") { + test("get should not throw") { // Given val os = OneSignalImp() - - // When - os.disableGMSMissingPrompt = true - - // Then - os.disableGMSMissingPrompt shouldBe true - // When - os.disableGMSMissingPrompt = false - + println(os.consentGiven) // Then - os.disableGMSMissingPrompt shouldBe false + // Test fails if the above throws } - - test("set should not throw") { - // Given - val os = OneSignalImp() - - // When & Then - should not throw - os.disableGMSMissingPrompt = true - os.disableGMSMissingPrompt = false - } - } - } - - context("property consistency tests") { - test("all properties maintain state correctly") { - // Given - val os = OneSignalImp() - - // When - set all properties to true - os.consentRequired = true - os.consentGiven = true - os.disableGMSMissingPrompt = true - - // Then - all should be true - os.consentRequired shouldBe true - os.consentGiven shouldBe true - os.disableGMSMissingPrompt shouldBe true - - // When - set all properties to false - os.consentRequired = false - os.consentGiven = false - os.disableGMSMissingPrompt = false - - // Then - all should be false - os.consentRequired shouldBe false - os.consentGiven shouldBe false - os.disableGMSMissingPrompt shouldBe false } - - test("properties are independent of each other") { - // Given - val os = OneSignalImp() - - // When - set only consentRequired to true - os.consentRequired = true - - // Then - only consentRequired should be true - os.consentRequired shouldBe true - os.consentGiven shouldBe false - os.disableGMSMissingPrompt shouldBe false - - // When - set only consentGiven to true - os.consentRequired = false - os.consentGiven = true - - // Then - only consentGiven should be true - os.consentRequired shouldBe false - os.consentGiven shouldBe true - os.disableGMSMissingPrompt shouldBe false - - // When - set only disableGMSMissingPrompt to true - os.consentGiven = false - os.disableGMSMissingPrompt = true - - // Then - only disableGMSMissingPrompt should be true - os.consentRequired shouldBe false - os.consentGiven shouldBe false - os.disableGMSMissingPrompt shouldBe true - } - } - - test("waitForInit timeout behavior - this test demonstrates the timeout mechanism") { - // This test documents that waitForInit() has timeout protection - // In a real scenario, if initWithContext was never called, - // waitForInit() would timeout after 30 seconds and throw an exception - - // Given - a fresh OneSignalImp instance - val oneSignalImp = OneSignalImp() - - // The timeout behavior is built into CompletionAwaiter.await() - // which waits for up to 30 seconds (or 4.8 seconds on main thread) - // before timing out and returning false - - // NOTE: We don't actually test the 30-second timeout here because: - // 1. It would make tests too slow (30 seconds per test) - // 2. The timeout is tested in CompletionAwaiterTests - // 3. This test documents the behavior for developers - - oneSignalImp.isInitialized shouldBe false - } - - test("waitForInit timeout mechanism exists - CompletionAwaiter integration") { - // This test verifies that the timeout mechanism is properly integrated - // by checking that CompletionAwaiter has timeout capabilities - - // Given - val oneSignalImp = OneSignalImp() - - // The timeout behavior is implemented through CompletionAwaiter.await() - // which has a default timeout of 30 seconds (or 4.8 seconds on main thread) - - // We can verify the timeout mechanism exists by checking: - // 1. The CompletionAwaiter is properly initialized - // 2. The initState is NOT_STARTED (which would trigger timeout) - // 3. The isInitialized property correctly reflects the state - - oneSignalImp.isInitialized shouldBe false - - // In a real scenario where initWithContext is never called: - // - waitForInit() would call initAwaiter.await() - // - CompletionAwaiter.await() would wait up to 30 seconds - // - After timeout, it would return false - // - waitForInit() would then throw "initWithContext was not called or timed out" - - // This test documents this behavior without actually waiting 30 seconds } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/AppIdHelperTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/AppIdHelperTests.kt deleted file mode 100644 index c2ec07e71c..0000000000 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/AppIdHelperTests.kt +++ /dev/null @@ -1,259 +0,0 @@ -package com.onesignal.user.internal - -import com.onesignal.core.internal.config.ConfigModel -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.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.mockk -import io.mockk.verify - -/** - * Unit tests for the resolveAppId function in AppIdResolution.kt - * - * These tests focus on the pure business logic of App ID resolution, - * complementing the integration tests in SDKInitTests.kt which test - * end-to-end SDK initialization behavior. - */ -class AppIdHelperTests : FunSpec({ - // Test constants - using consistent naming with SDKInitTests - val testAppId = "appId" - val differentAppId = "different-app-id" - val legacyAppId = "legacy-app-id" - - beforeEach { - Logging.logLevel = LogLevel.NONE - } - - test("resolveAppId with new appId and no existing appId forces user creation") { - // Given - fresh config model with no appId property - val configModel = ConfigModel() - // Don't set any appId - simulates fresh install - - val mockPreferencesService = mockk(relaxed = true) - - // When - val result = resolveAppId(testAppId, configModel, mockPreferencesService) - - // Then - result.appId shouldBe testAppId - result.forceCreateUser shouldBe true - result.failed shouldBe false - - // Should not check legacy preferences when appId is provided - verify(exactly = 0) { - mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) - } - } - - test("resolveAppId with same appId as existing does not force user creation") { - // Given - config model with existing appId - val configModel = ConfigModel() - configModel.appId = differentAppId - - val mockPreferencesService = mockk(relaxed = true) - - // When - val result = resolveAppId(differentAppId, configModel, mockPreferencesService) - - // Then - result.appId shouldBe differentAppId - result.forceCreateUser shouldBe false - result.failed shouldBe false - } - - test("resolveAppId with different appId than existing forces user creation") { - // Given - config model with different existing appId - val configModel = ConfigModel() - configModel.appId = differentAppId - - val mockPreferencesService = mockk(relaxed = true) - - // When - val result = resolveAppId(testAppId, configModel, mockPreferencesService) - - // Then - result.appId shouldBe testAppId - result.forceCreateUser shouldBe true - result.failed shouldBe false - } - - test("resolveAppId with null appId and existing appId in config returns existing") { - // Given - config model with existing appId - val configModel = ConfigModel() - configModel.appId = differentAppId - - val mockPreferencesService = mockk(relaxed = true) - - // When - val result = resolveAppId(null, configModel, mockPreferencesService) - - // Then - result.appId shouldBe differentAppId // should return the existing appId from configModel - result.forceCreateUser shouldBe false - result.failed shouldBe false - - // Should not check legacy preferences when config already has appId - verify(exactly = 0) { - mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) - } - } - - test("resolveAppId with null appId and no existing appId finds legacy appId") { - // Given - fresh config model with no appId property - val configModel = ConfigModel() - - val mockPreferencesService = mockk() - every { - mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) - } returns legacyAppId - - // When - val result = resolveAppId(null, configModel, mockPreferencesService) - - // Then - result.appId shouldBe legacyAppId - result.forceCreateUser shouldBe true // Legacy appId found forces user creation - result.failed shouldBe false - - // Should check legacy preferences - verify(exactly = 1) { - mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) - } - } - - test("resolveAppId with null appId and no existing appId and no legacy appId fails") { - // Given - fresh config model with no appId property and no legacy appId - val configModel = ConfigModel() - - val mockPreferencesService = mockk() - every { - mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) - } returns null - - // When - val result = resolveAppId(null, configModel, mockPreferencesService) - - // Then - result.appId shouldBe null - result.forceCreateUser shouldBe false - result.failed shouldBe true - - // Should check legacy preferences - verify(exactly = 1) { - mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) - } - } - - test("AppIdResolution data class has correct properties") { - // Given - val appIdResolution = - AppIdResolution( - appId = "test-app-id", - forceCreateUser = true, - failed = false, - ) - - // Then - appIdResolution.appId shouldBe "test-app-id" - appIdResolution.forceCreateUser shouldBe true - appIdResolution.failed shouldBe false - } - - test("AppIdResolution handles null appId correctly") { - // Given - val appIdResolution = - AppIdResolution( - appId = null, - forceCreateUser = false, - failed = true, - ) - - // Then - appIdResolution.appId shouldBe null - appIdResolution.forceCreateUser shouldBe false - appIdResolution.failed shouldBe true - } - - test("configModel hasProperty check works correctly with appId set") { - // Given - config model with appId explicitly set - val configModel = ConfigModel() - configModel.appId = differentAppId - - val mockPreferencesService = mockk(relaxed = true) - - // When - val result = resolveAppId(testAppId, configModel, mockPreferencesService) - - // Then - should detect property exists and force user creation due to different appId - result.appId shouldBe testAppId - result.forceCreateUser shouldBe true - result.failed shouldBe false - } - - test("empty string appId is treated as null") { - // Given - config model with no appId - val configModel = ConfigModel() - - val mockPreferencesService = mockk() - every { - mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) - } returns legacyAppId - - // When - pass empty string (which should be treated similar to null in practice) - val result = resolveAppId("", configModel, mockPreferencesService) - - // Then - empty string is still treated as a valid input appId - result.appId shouldBe "" - result.forceCreateUser shouldBe true - result.failed shouldBe false - - // Should not check legacy preferences when appId is provided (even if empty) - verify(exactly = 0) { - mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) - } - } - - test("resolveAppId with existing appId property but same value") { - // Given - config model with the same appId already set - val configModel = ConfigModel() - configModel.appId = differentAppId - - val mockPreferencesService = mockk(relaxed = true) - - // When - val result = resolveAppId(differentAppId, configModel, mockPreferencesService) - - // Then - should not force user creation when appId is unchanged - result.appId shouldBe differentAppId - result.forceCreateUser shouldBe false - result.failed shouldBe false - } - - test("legacy appId fallback when config model exists but has no appId property") { - // Given - config model that exists but doesn't have appId set - val configModel = ConfigModel() - // Don't set appId to simulate hasProperty returning false - - val mockPreferencesService = mockk() - every { - mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) - } returns legacyAppId - - // When - val result = resolveAppId(null, configModel, mockPreferencesService) - - // Then - result.appId shouldBe legacyAppId - result.forceCreateUser shouldBe true - result.failed shouldBe false - - verify(exactly = 1) { - mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) - } - } -}) 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 deleted file mode 100644 index a501e73bcf..0000000000 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt +++ /dev/null @@ -1,249 +0,0 @@ -package com.onesignal.user.internal - -import com.onesignal.core.internal.config.ConfigModel -import com.onesignal.core.internal.operations.IOperationRepo -import com.onesignal.debug.LogLevel -import com.onesignal.debug.internal.logging.Logging -import com.onesignal.mocks.MockHelper -import com.onesignal.user.internal.identity.IdentityModel -import com.onesignal.user.internal.operations.LoginUserOperation -import com.onesignal.user.internal.properties.PropertiesModel -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import kotlinx.coroutines.runBlocking - -/** - * Unit tests for the LoginHelper class - * - * These tests focus on the pure business logic of user login operations, - * complementing the integration tests in SDKInitTests.kt which test - * end-to-end SDK initialization and login behavior. - */ -class LoginHelperTests : FunSpec({ - // Test constants - using consistent naming with SDKInitTests - val appId = "appId" - val currentExternalId = "current-user" - val newExternalId = "new-user" - val currentOneSignalId = "current-onesignal-id" - val newOneSignalId = "new-onesignal-id" - - beforeEach { - Logging.logLevel = LogLevel.NONE - } - - test("login with same external id returns early without creating user") { - // Given - val mockIdentityModelStore = - MockHelper.identityModelStore { model -> - model.externalId = currentExternalId - model.onesignalId = currentOneSignalId - } - val mockUserSwitcher = mockk(relaxed = true) - val mockOperationRepo = mockk(relaxed = true) - val mockConfigModel = mockk() - every { mockConfigModel.appId } returns appId - val loginLock = Any() - - val loginHelper = - LoginHelper( - identityModelStore = mockIdentityModelStore, - userSwitcher = mockUserSwitcher, - operationRepo = mockOperationRepo, - configModel = mockConfigModel, - lock = loginLock, - ) - - // When - runBlocking { - loginHelper.login(currentExternalId) - } - - // Then - should return early without any operations - verify(exactly = 0) { mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = any(), modify = any()) } - coVerify(exactly = 0) { mockOperationRepo.enqueueAndWait(any()) } - } - - test("login with different external id creates and switches to new user") { - // Given - 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 - val loginLock = Any() - - 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.enqueueAndWait(any()) } returns true - - val loginHelper = - LoginHelper( - identityModelStore = mockIdentityModelStore, - userSwitcher = mockUserSwitcher, - operationRepo = mockOperationRepo, - configModel = mockConfigModel, - lock = loginLock, - ) - - // When - runBlocking { - loginHelper.login(newExternalId) - } - - // Then - should switch users and enqueue login operation - verify(exactly = 1) { mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = any(), modify = any()) } - - userSwitcherSlot.captured(newIdentityModel, PropertiesModel()) - newIdentityModel.externalId shouldBe newExternalId - - coVerify(exactly = 1) { - mockOperationRepo.enqueueAndWait( - withArg { operation -> - operation.appId shouldBe appId - operation.onesignalId shouldBe newOneSignalId - operation.externalId shouldBe newExternalId - operation.existingOnesignalId shouldBe null // Current user already has external ID, so no existing OneSignal ID - }, - ) - } - } - - test("login with null current external id provides existing onesignal id for conversion") { - // Given - anonymous user (no external ID) - val mockIdentityModelStore = - MockHelper.identityModelStore { model -> - model.externalId = null - 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 - val loginLock = Any() - - 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.enqueueAndWait(any()) } returns true - - val loginHelper = - LoginHelper( - identityModelStore = mockIdentityModelStore, - userSwitcher = mockUserSwitcher, - operationRepo = mockOperationRepo, - configModel = mockConfigModel, - lock = loginLock, - ) - - // When - runBlocking { - loginHelper.login(newExternalId) - } - - // Then - should provide existing OneSignal ID for anonymous user conversion - coVerify(exactly = 1) { - mockOperationRepo.enqueueAndWait( - withArg { operation -> - operation.appId shouldBe appId - operation.onesignalId shouldBe newOneSignalId - operation.externalId shouldBe newExternalId - operation.existingOnesignalId shouldBe currentOneSignalId // For conversion - }, - ) - } - } - - test("login logs error when operation fails") { - // Given - 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 - val loginLock = Any() - - val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() - every { - mockUserSwitcher.createAndSwitchToNewUser( - suppressBackendOperation = any(), - modify = capture(userSwitcherSlot), - ) - } answers { - userSwitcherSlot.captured(newIdentityModel, PropertiesModel()) - every { mockIdentityModelStore.model } returns newIdentityModel - } - - // Mock operation failure - coEvery { mockOperationRepo.enqueueAndWait(any()) } returns false - - val loginHelper = - LoginHelper( - identityModelStore = mockIdentityModelStore, - userSwitcher = mockUserSwitcher, - operationRepo = mockOperationRepo, - configModel = mockConfigModel, - lock = loginLock, - ) - - // When - runBlocking { - loginHelper.login(newExternalId) - } - - // Then - should still switch users but operation fails - verify(exactly = 1) { mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = any(), modify = any()) } - coVerify(exactly = 1) { mockOperationRepo.enqueueAndWait(any()) } - } -}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt deleted file mode 100644 index 4921ed6bb6..0000000000 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt +++ /dev/null @@ -1,171 +0,0 @@ -package com.onesignal.user.internal - -import com.onesignal.core.internal.config.ConfigModel -import com.onesignal.core.internal.operations.IOperationRepo -import com.onesignal.debug.LogLevel -import com.onesignal.debug.internal.logging.Logging -import com.onesignal.mocks.MockHelper -import com.onesignal.user.internal.operations.LoginUserOperation -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import io.mockk.verifyOrder - -/** - * Unit tests for the LogoutHelper class - * - * These tests focus on the pure business logic of user logout operations, - * complementing the integration tests in SDKInitTests.kt which test - * end-to-end SDK initialization and logout behavior. - */ -class LogoutHelperTests : FunSpec({ - // Test constants - using consistent naming with SDKInitTests - val appId = "appId" - val externalId = "current-user" - val onesignalId = "current-onesignal-id" - - beforeEach { - Logging.logLevel = LogLevel.NONE - } - - test("logout with no external id returns early without operations") { - // Given - anonymous user (no external ID) - val mockIdentityModelStore = - MockHelper.identityModelStore { model -> - model.externalId = null - model.onesignalId = onesignalId - } - val mockUserSwitcher = mockk(relaxed = true) - val mockOperationRepo = mockk(relaxed = true) - val mockConfigModel = mockk() - every { mockConfigModel.appId } returns appId - val logoutLock = Any() - - val logoutHelper = - LogoutHelper( - identityModelStore = mockIdentityModelStore, - userSwitcher = mockUserSwitcher, - operationRepo = mockOperationRepo, - configModel = mockConfigModel, - lock = logoutLock, - ) - - // When - logoutHelper.logout() - - // Then - should return early without any operations - verify(exactly = 0) { mockUserSwitcher.createAndSwitchToNewUser() } - verify(exactly = 0) { mockOperationRepo.enqueue(any()) } - } - - test("logout with external id creates new user and enqueues operation") { - // Given - identified user - val mockIdentityModelStore = - MockHelper.identityModelStore { model -> - model.externalId = externalId - model.onesignalId = onesignalId - } - val mockUserSwitcher = mockk(relaxed = true) - val mockOperationRepo = mockk(relaxed = true) - val mockConfigModel = mockk() - every { mockConfigModel.appId } returns appId - val logoutLock = Any() - - val logoutHelper = - LogoutHelper( - identityModelStore = mockIdentityModelStore, - userSwitcher = mockUserSwitcher, - operationRepo = mockOperationRepo, - configModel = mockConfigModel, - lock = logoutLock, - ) - - // When - logoutHelper.logout() - - // Then - should create new user and enqueue login operation for device-scoped user - verify(exactly = 1) { mockUserSwitcher.createAndSwitchToNewUser() } - verify(exactly = 1) { - mockOperationRepo.enqueue( - withArg { operation -> - operation.appId shouldBe appId - operation.onesignalId shouldBe onesignalId - operation.externalId shouldBe null // Device-scoped user after logout - operation.existingOnesignalId shouldBe null - }, - ) - } - } - - test("logout operations happen in correct order") { - // Given - identified user - val mockIdentityModelStore = - MockHelper.identityModelStore { model -> - model.externalId = externalId - model.onesignalId = onesignalId - } - val mockUserSwitcher = mockk(relaxed = true) - val mockOperationRepo = mockk(relaxed = true) - val mockConfigModel = mockk() - every { mockConfigModel.appId } returns appId - val logoutLock = Any() - - val logoutHelper = - LogoutHelper( - identityModelStore = mockIdentityModelStore, - userSwitcher = mockUserSwitcher, - operationRepo = mockOperationRepo, - configModel = mockConfigModel, - lock = logoutLock, - ) - - // When - logoutHelper.logout() - - // Then - operations should happen in the correct order - verifyOrder { - mockUserSwitcher.createAndSwitchToNewUser() - mockOperationRepo.enqueue(any()) - } - } - - test("logout is thread-safe with synchronized block") { - // Given - identified user - val mockIdentityModelStore = - MockHelper.identityModelStore { model -> - model.externalId = externalId - model.onesignalId = onesignalId - } - val mockUserSwitcher = mockk(relaxed = true) - val mockOperationRepo = mockk(relaxed = true) - val mockConfigModel = mockk() - every { mockConfigModel.appId } returns appId - val logoutLock = Any() - - val logoutHelper = - LogoutHelper( - identityModelStore = mockIdentityModelStore, - userSwitcher = mockUserSwitcher, - operationRepo = mockOperationRepo, - configModel = mockConfigModel, - lock = logoutLock, - ) - - // When - call logout multiple times concurrently - val threads = - (1..10).map { - Thread { - logoutHelper.logout() - } - } - - threads.forEach { it.start() } - threads.forEach { it.join() } - - // Then - due to synchronization, operations should complete properly - verify(atLeast = 1) { mockUserSwitcher.createAndSwitchToNewUser() } - verify(atLeast = 1) { mockOperationRepo.enqueue(any()) } - } -}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserSwitcherTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserSwitcherTests.kt deleted file mode 100644 index 18c4c53ea2..0000000000 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserSwitcherTests.kt +++ /dev/null @@ -1,419 +0,0 @@ -package com.onesignal.user.internal - -import android.content.Context -import com.onesignal.common.AndroidUtils -import com.onesignal.common.IDManager -import com.onesignal.common.OneSignalUtils -import com.onesignal.common.modeling.ModelChangeTags -import com.onesignal.common.services.ServiceProvider -import com.onesignal.core.internal.application.IApplicationService -import com.onesignal.core.internal.config.ConfigModel -import com.onesignal.core.internal.operations.IOperationRepo -import com.onesignal.core.internal.preferences.IPreferencesService -import com.onesignal.core.internal.preferences.getLegacyPlayerId -import com.onesignal.core.internal.preferences.getLegacyUserSyncValues -import com.onesignal.debug.LogLevel -import com.onesignal.debug.internal.logging.Logging -import com.onesignal.mocks.MockHelper -import com.onesignal.user.internal.backend.IdentityConstants -import com.onesignal.user.internal.identity.IdentityModel -import com.onesignal.user.internal.identity.IdentityModelStore -import com.onesignal.user.internal.operations.LoginUserFromSubscriptionOperation -import com.onesignal.user.internal.operations.LoginUserOperation -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 -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.spyk -import io.mockk.verify -import org.json.JSONObject -import java.util.Collections - -// Mocks used by every test in this file -private class Mocks { - // Test constants - using consistent naming with SDKInitTests - val appId = "appId" - val testOneSignalId = "test-onesignal-id" - val newOneSignalId = "new-onesignal-id" - val testExternalId = "test-external-id" - val testSubscriptionId = "test-subscription-id" - val testCarrier = "test-carrier" - val testDeviceOS = "13" - val testAppVersion = "1.0.0" - val legacyPlayerId = "legacy-player-id" - val legacyUserSyncJson = """{"notification_types":1,"identifier":"test-token"}""" - - val mockContext = mockk(relaxed = true) - val mockPreferencesService = mockk(relaxed = true) - val mockOperationRepo = mockk(relaxed = true) - val mockApplicationService = - mockk(relaxed = true).apply { - every { appContext } returns mockContext - } - val mockServices = - mockk(relaxed = true).apply { - every { getService(IApplicationService::class.java) } returns mockApplicationService - } - val mockConfigModel = mockk(relaxed = true) - val mockOneSignalUtils = spyk(OneSignalUtils) - - // No longer need DeviceUtils - we'll pass carrier name directly - val mockAndroidUtils = spyk(AndroidUtils) - val mockIdManager = mockk(relaxed = true) - - // Create fresh model stores for each test to avoid concurrent modification - fun createIdentityModelStore(): IdentityModelStore { - val store = MockHelper.identityModelStore() - // Set up replace method to actually update the model reference - every { store.replace(any()) } answers { - val newModel = firstArg() - every { store.model } returns newModel - } - return store - } - - fun createPropertiesModelStore() = mockk(relaxed = true) - - // Keep references to the latest created stores for verification in tests - var identityModelStore: IdentityModelStore? = null - var propertiesModelStore: PropertiesModelStore? = null - var subscriptionModelStore: SubscriptionModelStore? = null - - fun createSubscriptionModelStore(): SubscriptionModelStore { - // Use a synchronized list to prevent ConcurrentModificationException - val subscriptionList = mutableListOf().let { Collections.synchronizedList(it) } - val mockSubscriptionStore = mockk(relaxed = true) - every { mockSubscriptionStore.list() } answers { synchronized(subscriptionList) { subscriptionList.toList() } } - every { mockSubscriptionStore.add(any(), any()) } answers { - synchronized(subscriptionList) { subscriptionList.add(firstArg()) } - } - every { mockSubscriptionStore.clear(any()) } answers { - synchronized(subscriptionList) { subscriptionList.clear() } - } - every { mockSubscriptionStore.replaceAll(any>()) } answers { - synchronized(subscriptionList) { - subscriptionList.clear() - subscriptionList.addAll(firstArg()) - } - } - every { mockSubscriptionStore.replaceAll(any>(), any()) } answers { - synchronized(subscriptionList) { - subscriptionList.clear() - subscriptionList.addAll(firstArg()) - } - } - return mockSubscriptionStore - } - - init { - // Set up default mock behaviors - every { mockConfigModel.appId } returns appId - every { mockConfigModel.pushSubscriptionId } returns testSubscriptionId - every { mockIdManager.createLocalId() } returns newOneSignalId - every { mockOneSignalUtils.sdkVersion } returns "5.0.0" - every { mockAndroidUtils.getAppVersion(any()) } returns testAppVersion - every { mockPreferencesService.getString(any(), any()) } returns null - every { mockPreferencesService.getLegacyPlayerId() } returns null - every { mockPreferencesService.getLegacyUserSyncValues() } returns legacyUserSyncJson - every { mockOperationRepo.enqueue(any()) } just runs - } - - fun createUserSwitcher(): UserSwitcher { - // Create fresh instances for this test - identityModelStore = createIdentityModelStore() - propertiesModelStore = createPropertiesModelStore() - subscriptionModelStore = createSubscriptionModelStore() - - return UserSwitcher( - preferencesService = mockPreferencesService, - operationRepo = mockOperationRepo, - services = mockServices, - idManager = mockIdManager, - identityModelStore = identityModelStore!!, - propertiesModelStore = propertiesModelStore!!, - subscriptionModelStore = subscriptionModelStore!!, - configModel = mockConfigModel, - oneSignalUtils = mockOneSignalUtils, - carrierName = testCarrier, - deviceOS = testDeviceOS, - androidUtils = mockAndroidUtils, - appContextProvider = { mockContext }, - ) - } - - fun createExistingSubscription(): SubscriptionModel { - return SubscriptionModel().apply { - id = testSubscriptionId - type = SubscriptionType.PUSH - optedIn = false - address = "existing-token" - status = SubscriptionStatus.UNSUBSCRIBE - } - } -} - -/** - * Unit tests for the UserSwitcher class - * - * These tests focus on the pure business logic of user switching operations, - * complementing the integration tests in SDKInitTests.kt which test - * end-to-end SDK initialization and user switching behavior. - */ -class UserSwitcherTests : FunSpec({ - - beforeEach { - Logging.logLevel = LogLevel.NONE - // Clear mock recorded calls between tests to prevent verification issues - // Note: We can't clear all mocks here since they're created per-test - } - - test("createAndSwitchToNewUser creates new user with generated ID") { - // Given - val mocks = Mocks() - val userSwitcher = mocks.createUserSwitcher() - - // When - userSwitcher.createAndSwitchToNewUser() - - // Then - verify basic user creation flow - verify(atLeast = 1) { mocks.mockIdManager.createLocalId() } - verify(exactly = 1) { mocks.subscriptionModelStore!!.clear(ModelChangeTags.NO_PROPOGATE) } - verify(exactly = 1) { mocks.identityModelStore!!.replace(any()) } - verify(exactly = 1) { mocks.propertiesModelStore!!.replace(any()) } - verify(exactly = 1) { mocks.subscriptionModelStore!!.replaceAll(any>()) } - } - - test("createAndSwitchToNewUser with modify lambda applies modifications") { - // Given - val mocks = Mocks() - val userSwitcher = mocks.createUserSwitcher() - - // When - userSwitcher.createAndSwitchToNewUser { identityModel, _ -> - identityModel.externalId = mocks.testExternalId - } - - // Then - verify that the modify lambda is called and user creation happens - verify(exactly = 1) { mocks.identityModelStore!!.replace(any()) } - verify(exactly = 1) { mocks.propertiesModelStore!!.replace(any()) } - } - - test("createAndSwitchToNewUser with suppressBackendOperation prevents propagation") { - // Given - val mocks = Mocks() - val userSwitcher = mocks.createUserSwitcher() - - // When - userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) - - // Then - should use NO_PROPOGATE tag for subscription updates - verify(exactly = 1) { mocks.subscriptionModelStore!!.replaceAll(any>(), ModelChangeTags.NO_PROPOGATE) } - verify(exactly = 0) { mocks.subscriptionModelStore!!.replaceAll(any>()) } - } - - test("createAndSwitchToNewUser preserves existing subscription data") { - // Given - val mocks = Mocks() - val userSwitcher = mocks.createUserSwitcher() - val existingSubscription = mocks.createExistingSubscription() - mocks.subscriptionModelStore!!.add(existingSubscription, ModelChangeTags.NO_PROPOGATE) - - // When - userSwitcher.createAndSwitchToNewUser() - - // Then - new subscription should be created and model stores updated - verify(exactly = 1) { mocks.subscriptionModelStore!!.list() } - verify(exactly = 1) { mocks.subscriptionModelStore!!.replaceAll(any>()) } - } - - test("createPushSubscriptionFromLegacySync creates subscription from legacy data") { - // Given - val mocks = Mocks() - val legacyUserSyncJSON = JSONObject(mocks.legacyUserSyncJson) - val mockConfigModel = mockk(relaxed = true) - val mockSubscriptionModelStore = mockk(relaxed = true) - val userSwitcher = mocks.createUserSwitcher() - - // When - val result = - userSwitcher.createPushSubscriptionFromLegacySync( - legacyPlayerId = mocks.legacyPlayerId, - legacyUserSyncJSON = legacyUserSyncJSON, - configModel = mockConfigModel, - subscriptionModelStore = mockSubscriptionModelStore, - appContext = mocks.mockContext, - ) - - // Then - result shouldBe true - verify(exactly = 1) { mockConfigModel.pushSubscriptionId = mocks.legacyPlayerId } - verify(exactly = 1) { mockSubscriptionModelStore.add(any(), ModelChangeTags.NO_PROPOGATE) } - } - - test("initUser with forceCreateUser creates new user") { - // Given - val mocks = Mocks() - val userSwitcher = mocks.createUserSwitcher() - mocks.identityModelStore!!.model.onesignalId = mocks.newOneSignalId - - // When - userSwitcher.initUser(forceCreateUser = true) - - // Then - should create user and enqueue login operation - verify(exactly = 1) { mocks.mockOperationRepo.enqueue(any()) } - } - - test("initUser without force create but no existing OneSignal ID creates new user") { - // Given - val mocks = Mocks() - val userSwitcher = mocks.createUserSwitcher() - // Remove OneSignal ID property completely to simulate no existing user - mocks.identityModelStore!!.model.remove(IdentityConstants.ONESIGNAL_ID) - - // When - userSwitcher.initUser(forceCreateUser = false) - - // Then - should create user because no existing OneSignal ID - verify(exactly = 1) { mocks.mockOperationRepo.enqueue(any()) } - } - - test("initUser with existing OneSignal ID and no force create does nothing") { - // Given - val mocks = Mocks() - val userSwitcher = mocks.createUserSwitcher() - // Set up existing OneSignal ID - mocks.identityModelStore!!.model.onesignalId = mocks.testOneSignalId - - // When - userSwitcher.initUser(forceCreateUser = false) - - // Then - should not create new user or enqueue operations - verify(exactly = 0) { mocks.mockOperationRepo.enqueue(any()) } - // Note: Don't verify createLocalId count as it might be called during setup - } - - test("initUser with legacy player ID creates user from legacy data") { - // Given - val mocks = Mocks() - every { mocks.mockPreferencesService.getLegacyPlayerId() } returns mocks.legacyPlayerId - every { mocks.mockPreferencesService.getLegacyUserSyncValues() } returns mocks.legacyUserSyncJson - val userSwitcher = mocks.createUserSwitcher() - - // When - userSwitcher.initUser(forceCreateUser = true) - - // Then - should handle legacy migration path - verify(exactly = 1) { mocks.mockPreferencesService.getLegacyPlayerId() } - verify(exactly = 1) { mocks.mockOperationRepo.enqueue(any()) } - } - - // New focused tests for decomposed methods - - test("createNewUser creates device-scoped user and enqueues LoginUserOperation") { - // Given - val mocks = Mocks() - val userSwitcher = mocks.createUserSwitcher() - // Remove existing OneSignal ID to trigger user creation - mocks.identityModelStore!!.model.remove(IdentityConstants.ONESIGNAL_ID) - - // When - userSwitcher.initUser(forceCreateUser = false) - - // Then - should create new user and enqueue standard login operation - verify(atLeast = 1) { mocks.mockIdManager.createLocalId() } - verify(exactly = 1) { mocks.identityModelStore!!.replace(any()) } - verify(exactly = 1) { mocks.propertiesModelStore!!.replace(any()) } - verify(exactly = 1) { mocks.mockOperationRepo.enqueue(any()) } - } - - test("migrateFromLegacyUser handles v4 to v5 migration with legacy sync data") { - // Given - val mocks = Mocks() - every { mocks.mockPreferencesService.getLegacyPlayerId() } returns mocks.legacyPlayerId - every { mocks.mockPreferencesService.getLegacyUserSyncValues() } returns mocks.legacyUserSyncJson - every { mocks.mockPreferencesService.saveString(any(), any(), any()) } just runs - val userSwitcher = mocks.createUserSwitcher() - - // When - userSwitcher.initUser(forceCreateUser = true) - - // Then - should migrate legacy data and enqueue subscription-based login - verify(exactly = 1) { mocks.mockPreferencesService.getLegacyPlayerId() } - verify(exactly = 1) { mocks.mockPreferencesService.getLegacyUserSyncValues() } - verify(exactly = 1) { mocks.mockOperationRepo.enqueue(any()) } - // Should clear legacy player ID after migration - verify(exactly = 1) { mocks.mockPreferencesService.saveString(any(), any(), null) } - } - - test("migrateFromLegacyUser handles v4 to v5 migration without legacy sync data") { - // Given - val mocks = Mocks() - every { mocks.mockPreferencesService.getLegacyPlayerId() } returns mocks.legacyPlayerId - every { mocks.mockPreferencesService.getLegacyUserSyncValues() } returns null - every { mocks.mockPreferencesService.saveString(any(), any(), any()) } just runs - val userSwitcher = mocks.createUserSwitcher() - - // When - userSwitcher.initUser(forceCreateUser = true) - - // Then - should still migrate but without creating subscription from sync data - verify(exactly = 1) { mocks.mockPreferencesService.getLegacyPlayerId() } - verify(exactly = 1) { mocks.mockPreferencesService.getLegacyUserSyncValues() } - verify(exactly = 1) { mocks.mockOperationRepo.enqueue(any()) } - // Should still clear legacy player ID - verify(exactly = 1) { mocks.mockPreferencesService.saveString(any(), any(), null) } - } - - test("initUser with forceCreateUser=true always creates new user even with existing OneSignal ID") { - // Given - val mocks = Mocks() - val userSwitcher = mocks.createUserSwitcher() - // Set up existing OneSignal ID - mocks.identityModelStore!!.model.onesignalId = mocks.testOneSignalId - - // When - userSwitcher.initUser(forceCreateUser = true) - - // Then - should create new user despite existing ID - verify(exactly = 1) { mocks.mockOperationRepo.enqueue(any()) } - verify(atLeast = 1) { mocks.mockIdManager.createLocalId() } - } - - test("initUser delegates to createNewUser when no legacy player ID exists") { - // Given - val mocks = Mocks() - every { mocks.mockPreferencesService.getLegacyPlayerId() } returns null - val userSwitcher = mocks.createUserSwitcher() - mocks.identityModelStore!!.model.remove(IdentityConstants.ONESIGNAL_ID) - - // When - userSwitcher.initUser(forceCreateUser = false) - - // Then - should follow new user creation path - verify(exactly = 1) { mocks.mockPreferencesService.getLegacyPlayerId() } - verify(exactly = 1) { mocks.mockOperationRepo.enqueue(any()) } - } - - test("initUser delegates to migrateFromLegacyUser when legacy player ID exists") { - // Given - val mocks = Mocks() - every { mocks.mockPreferencesService.getLegacyPlayerId() } returns mocks.legacyPlayerId - every { mocks.mockPreferencesService.getLegacyUserSyncValues() } returns null - every { mocks.mockPreferencesService.saveString(any(), any(), any()) } just runs - val userSwitcher = mocks.createUserSwitcher() - - // When - userSwitcher.initUser(forceCreateUser = true) - - // Then - should follow legacy migration path - verify(exactly = 1) { mocks.mockPreferencesService.getLegacyPlayerId() } - verify(exactly = 1) { mocks.mockOperationRepo.enqueue(any()) } - } -}) 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 0fa15fdcae..f8b5839356 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 @@ -334,10 +334,10 @@ class UserBackendServiceTests : FunSpec({ PropertiesDeltasObject( amountSpent = BigDecimal(1111), purchases = - listOf( - PurchaseObject("sku1", "iso1", BigDecimal(2222)), - PurchaseObject("sku2", "iso2", BigDecimal(4444)), - ), + listOf( + PurchaseObject("sku1", "iso1", BigDecimal(2222)), + PurchaseObject("sku2", "iso2", BigDecimal(4444)), + ), ) // When diff --git a/OneSignalSDK/onesignal/in-app-messages/build.gradle b/OneSignalSDK/onesignal/in-app-messages/build.gradle index 2e5b0e0ae3..4dbf48cf5e 100644 --- a/OneSignalSDK/onesignal/in-app-messages/build.gradle +++ b/OneSignalSDK/onesignal/in-app-messages/build.gradle @@ -66,9 +66,9 @@ ext { dependencies { implementation project(':OneSignal:core') implementation project(':OneSignal:notifications') - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" api('androidx.cardview:cardview') { version { diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt index 2b68fe3457..89659703ba 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt @@ -11,9 +11,7 @@ import com.onesignal.common.events.EventProducer import com.onesignal.common.exceptions.BackendException import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler import com.onesignal.common.modeling.ModelChangedArgs -import com.onesignal.common.threading.suspendifyOnDefault -import com.onesignal.common.threading.suspendifyOnIO -import com.onesignal.common.threading.suspendifyOnMain +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.internal.application.IApplicationLifecycleHandler import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModel @@ -53,6 +51,9 @@ import com.onesignal.user.internal.subscriptions.ISubscriptionManager import com.onesignal.user.internal.subscriptions.SubscriptionModel import com.onesignal.user.subscriptions.IPushSubscription import com.onesignal.user.subscriptions.ISubscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -133,7 +134,7 @@ internal class InAppMessagesManager( // Create a IAM fetch condition when a backend OneSignalID is retrieved for the first time if (IDManager.isLocalId(oldOneSignalId) && !IDManager.isLocalId(newOneSignalId)) { - suspendifyOnIO { + suspendifyOnThread { val updateConditionDeferred = _consistencyManager.getRywDataFromAwaitableCondition(IamFetchReadyCondition(newOneSignalId)) val rywToken = updateConditionDeferred.await() @@ -154,13 +155,13 @@ internal class InAppMessagesManager( // If paused is true and an In-App Message is showing, dismiss it if (value && _state.inAppMessageIdShowing != null) { - suspendifyOnMain { + GlobalScope.launch(Dispatchers.Main) { _displayer.dismissCurrentInAppMessage() } } if (!value) { - suspendifyOnDefault { + suspendifyOnThread { evaluateInAppMessages() } } @@ -185,7 +186,7 @@ internal class InAppMessagesManager( _applicationService.addApplicationLifecycleHandler(this) _identityModelStore.subscribe(identityModelChangeHandler) - suspendifyOnIO { + suspendifyOnThread { _repository.cleanCachedInAppMessages() // get saved IAMs from database @@ -264,7 +265,7 @@ internal class InAppMessagesManager( override fun onSessionEnded(duration: Long) { } private fun fetchMessagesWhenConditionIsMet() { - suspendifyOnIO { + suspendifyOnThread { val onesignalId = _userManager.onesignalId val iamFetchCondition = _consistencyManager.getRywDataFromAwaitableCondition(IamFetchReadyCondition(onesignalId)) @@ -624,7 +625,7 @@ internal class InAppMessagesManager( val variantId = InAppHelper.variantIdForMessage(message, _languageContext) ?: return - suspendifyOnIO { + suspendifyOnThread { try { _backend.sendIAMImpression( _configModelStore.model.appId, @@ -645,7 +646,7 @@ internal class InAppMessagesManager( message: InAppMessage, action: InAppMessageClickResult, ) { - suspendifyOnIO { + suspendifyOnThread { action.isFirstClick = message.takeActionAsUnique() firePublicClickHandler(message, action) @@ -659,7 +660,7 @@ internal class InAppMessagesManager( message: InAppMessage, action: InAppMessageClickResult, ) { - suspendifyOnIO { + suspendifyOnThread { action.isFirstClick = message.takeActionAsUnique() firePublicClickHandler(message, action) beginProcessingPrompts(message, action.prompts) @@ -678,7 +679,7 @@ internal class InAppMessagesManager( return } - suspendifyOnIO { + suspendifyOnThread { fireRESTCallForPageChange(message, page) } } @@ -692,7 +693,7 @@ internal class InAppMessagesManager( } override fun onMessageWasDismissed(message: InAppMessage) { - suspendifyOnIO { + suspendifyOnThread { messageWasDismissed(message) } } @@ -726,7 +727,7 @@ internal class InAppMessagesManager( makeRedisplayMessagesAvailableWithTriggers(listOf(triggerId), false) - suspendifyOnDefault { + suspendifyOnThread { // This method is called when a time-based trigger timer fires, meaning the message can // probably be shown now. So the current message conditions should be re-evaluated evaluateInAppMessages() @@ -738,7 +739,7 @@ internal class InAppMessagesManager( makeRedisplayMessagesAvailableWithTriggers(listOf(newTriggerKey), true) - suspendifyOnDefault { + suspendifyOnThread { // This method is called when a time-based trigger timer fires, meaning the message can // probably be shown now. So the current message conditions should be re-evaluated evaluateInAppMessages() @@ -950,7 +951,7 @@ internal class InAppMessagesManager( .Builder(_applicationService.current) .setTitle(messageTitle) .setMessage(message) - .setPositiveButton(android.R.string.ok) { _, _ -> suspendifyOnIO { showMultiplePrompts(inAppMessage, prompts) } } + .setPositiveButton(android.R.string.ok) { _, _ -> suspendifyOnThread { showMultiplePrompts(inAppMessage, prompts) } } .show() } diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/InAppMessageView.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/InAppMessageView.kt index 11098714f8..2a75305e29 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/InAppMessageView.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/InAppMessageView.kt @@ -20,7 +20,7 @@ import androidx.core.widget.PopupWindowCompat import com.onesignal.common.AndroidUtils import com.onesignal.common.ViewUtils import com.onesignal.common.threading.Waiter -import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.debug.internal.logging.Logging import com.onesignal.inAppMessages.internal.InAppMessageContent import kotlinx.coroutines.Dispatchers @@ -347,7 +347,7 @@ internal class InAppMessageView( messageController!!.onMessageWillDismiss() } - suspendifyOnIO { + suspendifyOnThread { finishAfterDelay() } } @@ -579,7 +579,7 @@ internal class InAppMessageView( messageView, ( -height - marginPxSizeTop - ).toFloat(), + ).toFloat(), 0f, IN_APP_BANNER_ANIMATION_DURATION_MS, OneSignalBounceInterpolator(0.1, 8.0), @@ -598,7 +598,7 @@ internal class InAppMessageView( messageView, ( height + marginPxSizeBottom - ).toFloat(), + ).toFloat(), 0f, IN_APP_BANNER_ANIMATION_DURATION_MS, OneSignalBounceInterpolator(0.1, 8.0), diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/OneSignalBounceInterpolator.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/OneSignalBounceInterpolator.kt index ded91c1632..ee4c73da26 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/OneSignalBounceInterpolator.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/OneSignalBounceInterpolator.kt @@ -13,7 +13,7 @@ internal class OneSignalBounceInterpolator(amplitude: Double, frequency: Double) Math.E, -time / mAmplitude, ) * Math.cos(mFrequency * time) + 1 - ).toFloat() + ).toFloat() } init { diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/WebViewManager.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/WebViewManager.kt index d509b9494c..a195b56d78 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/WebViewManager.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/WebViewManager.kt @@ -10,9 +10,8 @@ import android.webkit.WebView import com.onesignal.common.AndroidUtils import com.onesignal.common.ViewUtils import com.onesignal.common.safeString -import com.onesignal.common.threading.suspendifyOnDefault -import com.onesignal.common.threading.suspendifyOnIO import com.onesignal.common.threading.suspendifyOnMain +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.internal.application.IActivityLifecycleHandler import com.onesignal.core.internal.application.IApplicationService import com.onesignal.debug.LogLevel @@ -236,7 +235,7 @@ internal class WebViewManager( try { val pagePxHeight = pageRectToViewHeight(activity, JSONObject(value)) - suspendifyOnIO { + suspendifyOnThread { showMessageView(pagePxHeight) } } catch (e: JSONException) { @@ -411,7 +410,7 @@ internal class WebViewManager( } fun backgroundDismissAndAwaitNextMessage() { - suspendifyOnDefault { + suspendifyOnThread { dismissAndAwaitNextMessage() } } diff --git a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/preview/InAppMessagePreviewHandlerTests.kt b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/preview/InAppMessagePreviewHandlerTests.kt index 81c0db4260..b7e1967e70 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/preview/InAppMessagePreviewHandlerTests.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/preview/InAppMessagePreviewHandlerTests.kt @@ -193,9 +193,11 @@ class InAppMessagePreviewHandlerTests : FunSpec({ ), ) - val controller1 = Robolectric.buildActivity(Activity::class.java) - controller1.setup() // Moves Activity to RESUMED state - val activity = controller1.get() + val activity: Activity + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity = controller.get() + } // When val response = inAppMessagePreviewHandler.canOpenNotification(activity, jsonObject) @@ -246,9 +248,11 @@ class InAppMessagePreviewHandlerTests : FunSpec({ ), ) - val controller1 = Robolectric.buildActivity(Activity::class.java) - controller1.setup() // Moves Activity to RESUMED state - val activity = controller1.get() + val activity: Activity + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity = controller.get() + } // When val response = inAppMessagePreviewHandler.canOpenNotification(activity, jsonObject) diff --git a/OneSignalSDK/onesignal/location/build.gradle b/OneSignalSDK/onesignal/location/build.gradle index a7ed57f127..37bbfb4863 100644 --- a/OneSignalSDK/onesignal/location/build.gradle +++ b/OneSignalSDK/onesignal/location/build.gradle @@ -65,9 +65,9 @@ ext { dependencies { implementation project(':OneSignal:core') - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" // play-services-location:16.0.0 is the last version before going to AndroidX // play-services-location:17.0.0 is the first version using AndroidX diff --git a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/LocationManager.kt b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/LocationManager.kt index fe82884e57..903183d369 100644 --- a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/LocationManager.kt +++ b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/LocationManager.kt @@ -2,7 +2,7 @@ package com.onesignal.location.internal import android.os.Build import com.onesignal.common.AndroidUtils -import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.preferences.IPreferencesService import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys @@ -41,7 +41,7 @@ internal class LocationManager( override fun start() { _locationPermissionController.subscribe(this) if (LocationUtils.hasLocationPermission(_applicationService.appContext)) { - suspendifyOnIO { + suspendifyOnThread { startGetLocation() } } @@ -49,7 +49,7 @@ internal class LocationManager( override fun onLocationPermissionChanged(enabled: Boolean) { if (enabled) { - suspendifyOnIO { + suspendifyOnThread { startGetLocation() } } diff --git a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/common/LocationUtils.kt b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/common/LocationUtils.kt index 4b159a492d..a69305bca8 100644 --- a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/common/LocationUtils.kt +++ b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/common/LocationUtils.kt @@ -28,6 +28,6 @@ internal object LocationUtils { return ( ContextCompat.checkSelfPermission(context, "android.permission.ACCESS_FINE_LOCATION") === PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(context, "android.permission.ACCESS_COARSE_LOCATION") === PackageManager.PERMISSION_GRANTED - ) + ) } } diff --git a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/controller/impl/GmsLocationController.kt b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/controller/impl/GmsLocationController.kt index 6c497ffc2f..2d1ad00402 100644 --- a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/controller/impl/GmsLocationController.kt +++ b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/controller/impl/GmsLocationController.kt @@ -10,7 +10,7 @@ import com.google.android.gms.location.LocationListener import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationServices import com.onesignal.common.events.EventProducer -import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.internal.application.IApplicationLifecycleHandler import com.onesignal.core.internal.application.IApplicationService import com.onesignal.debug.LogLevel @@ -152,7 +152,7 @@ internal class GmsLocationController( override fun onConnectionFailed(connectionResult: ConnectionResult) { Logging.debug("GMSLocationController GoogleApiClientListener onConnectionSuspended connectionResult: $connectionResult") - suspendifyOnIO { + suspendifyOnThread { _parent.stop() } } @@ -229,13 +229,13 @@ internal class GmsLocationController( protected class LocationHandlerThread internal constructor() : HandlerThread("OSH_LocationHandlerThread") { - var mHandler: Handler + var mHandler: Handler - init { - start() - mHandler = Handler(looper) + init { + start() + mHandler = Handler(looper) + } } - } companion object { val API_FALLBACK_TIME = 30000 diff --git a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/controller/impl/HmsLocationController.kt b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/controller/impl/HmsLocationController.kt index 04bf9bc258..98dd1dec80 100644 --- a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/controller/impl/HmsLocationController.kt +++ b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/controller/impl/HmsLocationController.kt @@ -11,7 +11,7 @@ import com.huawei.hms.location.LocationResult import com.onesignal.common.events.EventProducer import com.onesignal.common.threading.Waiter import com.onesignal.common.threading.WaiterWithValue -import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.internal.application.IApplicationLifecycleHandler import com.onesignal.core.internal.application.IApplicationService import com.onesignal.debug.LogLevel @@ -116,7 +116,7 @@ internal class HmsLocationController( var retVal: Location? = null - suspendifyOnIO { + suspendifyOnThread { var waiter = Waiter() locationClient.lastLocation .addOnSuccessListener( @@ -212,11 +212,11 @@ internal class HmsLocationController( class LocationHandlerThread internal constructor() : HandlerThread("OSH_LocationHandlerThread") { - var mHandler: Handler + var mHandler: Handler - init { - start() - mHandler = Handler(looper) + init { + start() + mHandler = Handler(looper) + } } - } } diff --git a/OneSignalSDK/onesignal/notifications/build.gradle b/OneSignalSDK/onesignal/notifications/build.gradle index 3fb47485b2..8f8be269c8 100644 --- a/OneSignalSDK/onesignal/notifications/build.gradle +++ b/OneSignalSDK/onesignal/notifications/build.gradle @@ -67,9 +67,9 @@ dependencies { compileOnly fileTree(dir: 'libs', include: ['*.jar']) implementation project(':OneSignal:core') - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" implementation 'androidx.work:work-runtime-ktx:2.8.1' compileOnly('com.amazon.device:amazon-appstore-sdk:[3.0.1, 3.0.99]') diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/NotificationOpenedActivityHMS.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/NotificationOpenedActivityHMS.kt index ba5679710e..22fb65b536 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/NotificationOpenedActivityHMS.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/NotificationOpenedActivityHMS.kt @@ -30,7 +30,7 @@ package com.onesignal import android.app.Activity import android.content.Intent import android.os.Bundle -import com.onesignal.common.threading.suspendifyOnDefault +import com.onesignal.common.threading.suspendifyBlocking import com.onesignal.notifications.internal.open.INotificationOpenedProcessorHMS // HMS Core creates a notification with an Intent when opened to start this Activity. @@ -72,13 +72,13 @@ class NotificationOpenedActivityHMS : Activity() { } private fun processOpen(intent: Intent?) { - suspendifyOnDefault { - if (!OneSignal.initWithContext(applicationContext)) { - return@suspendifyOnDefault - } + if (!OneSignal.initWithContext(applicationContext)) { + return + } - val notificationPayloadProcessorHMS = OneSignal.getService() - val self = this + var notificationPayloadProcessorHMS = OneSignal.getService() + val self = this + suspendifyBlocking { notificationPayloadProcessorHMS.handleHMSNotificationOpenIntent(self, intent) } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/activities/NotificationOpenedActivityBase.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/activities/NotificationOpenedActivityBase.kt index be85c7dc26..b083e6107e 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/activities/NotificationOpenedActivityBase.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/activities/NotificationOpenedActivityBase.kt @@ -31,7 +31,7 @@ import android.content.Intent import android.os.Bundle import com.onesignal.OneSignal import com.onesignal.common.AndroidUtils -import com.onesignal.common.threading.suspendifyOnDefault +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.notifications.internal.open.INotificationOpenedProcessor abstract class NotificationOpenedActivityBase : Activity() { @@ -46,23 +46,25 @@ abstract class NotificationOpenedActivityBase : Activity() { } internal open fun processIntent() { - suspendifyOnDefault { - if (!OneSignal.initWithContext(applicationContext)) { - return@suspendifyOnDefault - } - - val openedProcessor = OneSignal.getService() - openedProcessor.processFromContext(this, intent) - // KEEP: Xiaomi Compatibility: - // Must keep this Activity alive while trampolining, that is - // startActivity() must be called BEFORE finish(), otherwise - // the app is never foregrounded. - - // Safely finish the activity on the main thread after processing is complete. - // This gives the system enough time to complete rendering before closing the Trampoline activity. - runOnUiThread { - AndroidUtils.finishSafely(this) - } + if (!OneSignal.initWithContext(applicationContext)) { + return } + suspendifyOnThread( + block = { + val openedProcessor = OneSignal.getService() + openedProcessor.processFromContext(this, intent) + // KEEP: Xiaomi Compatibility: + // Must keep this Activity alive while trampolining, that is + // startActivity() must be called BEFORE finish(), otherwise + // the app is never foregrounded. + }, + onComplete = { + // Safely finish the activity on the main thread after processing is complete. + // This gives the system enough time to complete rendering before closing the Trampoline activity. + runOnUiThread { + AndroidUtils.finishSafely(this) + } + }, + ) } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/bridges/OneSignalHmsEventBridge.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/bridges/OneSignalHmsEventBridge.kt index 8fd06d90a6..7a9a8fef54 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/bridges/OneSignalHmsEventBridge.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/bridges/OneSignalHmsEventBridge.kt @@ -5,8 +5,7 @@ import android.os.Bundle import com.huawei.hms.push.RemoteMessage import com.onesignal.OneSignal import com.onesignal.common.JSONUtils -import com.onesignal.common.threading.suspendifyOnDefault -import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.internal.time.ITime import com.onesignal.debug.internal.logging.Logging import com.onesignal.notifications.internal.bundle.INotificationBundleProcessor @@ -39,8 +38,8 @@ object OneSignalHmsEventBridge { ) { if (firstToken.compareAndSet(true, false)) { Logging.info("OneSignalHmsEventBridge onNewToken - HMS token: $token Bundle: $bundle") - suspendifyOnIO { - val registerer = OneSignal.getService() + var registerer = OneSignal.getService() + suspendifyOnThread { registerer.fireCallback(token) } } else { @@ -64,44 +63,42 @@ object OneSignalHmsEventBridge { context: Context, message: RemoteMessage, ) { - suspendifyOnDefault { - if (!OneSignal.initWithContext(context)) { - return@suspendifyOnDefault - } - - val time = OneSignal.getService() - val bundleProcessor = OneSignal.getService() - - var data = message.data - try { - val messageDataJSON = JSONObject(message.data) - if (message.ttl == 0) { - messageDataJSON.put(HMS_TTL_KEY, NotificationConstants.DEFAULT_TTL_IF_NOT_IN_PAYLOAD) - } else { - messageDataJSON.put(HMS_TTL_KEY, message.ttl) - } + if (!OneSignal.initWithContext(context)) { + return + } - if (message.sentTime == 0L) { - messageDataJSON.put(HMS_SENT_TIME_KEY, time.currentTimeMillis) - } else { - messageDataJSON.put(HMS_SENT_TIME_KEY, message.sentTime) - } + var time = OneSignal.getService() + val bundleProcessor = OneSignal.getService() - data = messageDataJSON.toString() - } catch (e: JSONException) { - Logging.error("OneSignalHmsEventBridge error when trying to create RemoteMessage data JSON") + var data = message.data + try { + val messageDataJSON = JSONObject(message.data) + if (message.ttl == 0) { + messageDataJSON.put(HMS_TTL_KEY, NotificationConstants.DEFAULT_TTL_IF_NOT_IN_PAYLOAD) + } else { + messageDataJSON.put(HMS_TTL_KEY, message.ttl) } - // HMS notification with Message Type being Message won't trigger Activity reverse trampolining logic - // for this case OneSignal rely on NotificationOpenedActivityHMS activity - // Last EMUI (12 to the date) is based on Android 10, so no - // Activity trampolining restriction exist for HMS devices - if (data == null) { - return@suspendifyOnDefault + if (message.sentTime == 0L) { + messageDataJSON.put(HMS_SENT_TIME_KEY, time.currentTimeMillis) + } else { + messageDataJSON.put(HMS_SENT_TIME_KEY, message.sentTime) } - val bundle = JSONUtils.jsonStringToBundle(data) ?: return@suspendifyOnDefault - bundleProcessor.processBundleFromReceiver(context, bundle) + data = messageDataJSON.toString() + } catch (e: JSONException) { + Logging.error("OneSignalHmsEventBridge error when trying to create RemoteMessage data JSON") } + + // HMS notification with Message Type being Message won't trigger Activity reverse trampolining logic + // for this case OneSignal rely on NotificationOpenedActivityHMS activity + // Last EMUI (12 to the date) is based on Android 10, so no + // Activity trampolining restriction exist for HMS devices + if (data == null) { + return + } + + val bundle = JSONUtils.jsonStringToBundle(data) ?: return + bundleProcessor.processBundleFromReceiver(context, bundle) } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/NotificationsManager.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/NotificationsManager.kt index fd5578e480..f835a4a502 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/NotificationsManager.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/NotificationsManager.kt @@ -2,7 +2,7 @@ package com.onesignal.notifications.internal import android.app.Activity import com.onesignal.common.events.EventProducer -import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.internal.application.IApplicationLifecycleHandler import com.onesignal.core.internal.application.IApplicationService import com.onesignal.debug.internal.logging.Logging @@ -53,7 +53,7 @@ internal class NotificationsManager( _applicationService.addApplicationLifecycleHandler(this) _notificationPermissionController.subscribe(this) - suspendifyOnIO { + suspendifyOnThread { _notificationDataController.deleteExpiredNotifications() } } @@ -104,7 +104,7 @@ internal class NotificationsManager( override fun removeNotification(id: Int) { Logging.debug("NotificationsManager.removeNotification(id: $id)") - suspendifyOnIO { + suspendifyOnThread { if (_notificationDataController.markAsDismissed(id)) { _summaryManager.updatePossibleDependentSummaryOnDismiss(id) } @@ -114,7 +114,7 @@ internal class NotificationsManager( override fun removeGroupedNotifications(group: String) { Logging.debug("NotificationsManager.removeGroupedNotifications(group: $group)") - suspendifyOnIO { + suspendifyOnThread { _notificationDataController.markAsDismissedForGroup(group) } } @@ -122,7 +122,7 @@ internal class NotificationsManager( override fun clearAllNotifications() { Logging.debug("NotificationsManager.clearAllNotifications()") - suspendifyOnIO { + suspendifyOnThread { _notificationDataController.markAsDismissedForOutstanding() } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/common/NotificationHelper.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/common/NotificationHelper.kt index 0743dab8f5..663e8d81c6 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/common/NotificationHelper.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/common/NotificationHelper.kt @@ -79,7 +79,7 @@ object NotificationHelper { val isGroupless = ( notification.group == null || notification.group == GROUPLESS_SUMMARY_KEY - ) + ) if (!isGroupSummary && isGroupless) { grouplessStatusBarNotifications.add( statusBarNotification, diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/data/impl/NotificationRepository.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/data/impl/NotificationRepository.kt index 66c750e3c0..b09bc4e157 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/data/impl/NotificationRepository.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/data/impl/NotificationRepository.kt @@ -56,8 +56,8 @@ internal class NotificationRepository( OneSignalDbContract.NotificationTable.TABLE_NAME, columns = retColumn, whereClause = - OneSignalDbContract.NotificationTable.COLUMN_NAME_DISMISSED.toString() + " = 0 AND " + - OneSignalDbContract.NotificationTable.COLUMN_NAME_OPENED + " = 0", + OneSignalDbContract.NotificationTable.COLUMN_NAME_DISMISSED.toString() + " = 0 AND " + + OneSignalDbContract.NotificationTable.COLUMN_NAME_OPENED + " = 0", ) { if (it.moveToFirst()) { do { @@ -384,9 +384,9 @@ internal class NotificationRepository( // retColumn columns = arrayOf(OneSignalDbContract.NotificationTable.COLUMN_NAME_ANDROID_NOTIFICATION_ID), whereClause = - OneSignalDbContract.NotificationTable.COLUMN_NAME_COLLAPSE_ID + " = ? AND " + - OneSignalDbContract.NotificationTable.COLUMN_NAME_DISMISSED + " = 0 AND " + - OneSignalDbContract.NotificationTable.COLUMN_NAME_OPENED + " = 0 ", + OneSignalDbContract.NotificationTable.COLUMN_NAME_COLLAPSE_ID + " = ? AND " + + OneSignalDbContract.NotificationTable.COLUMN_NAME_DISMISSED + " = 0 AND " + + OneSignalDbContract.NotificationTable.COLUMN_NAME_OPENED + " = 0 ", whereArgs = arrayOf(collapseKey), ) { if (it.moveToFirst()) { @@ -444,10 +444,10 @@ internal class NotificationRepository( OneSignalDbContract.NotificationTable.TABLE_NAME, columns = COLUMNS_FOR_LIST_NOTIFICATIONS, whereClause = - OneSignalDbContract.NotificationTable.COLUMN_NAME_GROUP_ID + " = ? AND " + // Where String - OneSignalDbContract.NotificationTable.COLUMN_NAME_DISMISSED + " = 0 AND " + - OneSignalDbContract.NotificationTable.COLUMN_NAME_OPENED + " = 0 AND " + - OneSignalDbContract.NotificationTable.COLUMN_NAME_IS_SUMMARY + " = 0", + OneSignalDbContract.NotificationTable.COLUMN_NAME_GROUP_ID + " = ? AND " + // Where String + OneSignalDbContract.NotificationTable.COLUMN_NAME_DISMISSED + " = 0 AND " + + OneSignalDbContract.NotificationTable.COLUMN_NAME_OPENED + " = 0 AND " + + OneSignalDbContract.NotificationTable.COLUMN_NAME_IS_SUMMARY + " = 0", whereArgs = whereArgs, // sort order, new to old); orderBy = BaseColumns._ID + " DESC", diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationProcessor.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationProcessor.kt index a552f0cc1a..3671abda70 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationProcessor.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationProcessor.kt @@ -3,7 +3,6 @@ package com.onesignal.notifications.internal.generation.impl import android.content.Context import com.onesignal.common.AndroidUtils import com.onesignal.common.safeString -import com.onesignal.common.threading.launchOnIO import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.time.ITime @@ -18,8 +17,11 @@ import com.onesignal.notifications.internal.display.INotificationDisplayer import com.onesignal.notifications.internal.generation.INotificationGenerationProcessor import com.onesignal.notifications.internal.lifecycle.INotificationLifecycleService import com.onesignal.notifications.internal.summary.INotificationSummaryManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import org.json.JSONException import org.json.JSONObject @@ -68,7 +70,7 @@ internal class NotificationGenerationProcessor( try { val notificationReceivedEvent = NotificationReceivedEvent(context, notification) withTimeout(30000L) { - launchOnIO { + GlobalScope.launch(Dispatchers.IO) { _lifecycleService.externalRemoteNotificationReceived(notificationReceivedEvent) if (notificationReceivedEvent.discard) { @@ -101,7 +103,7 @@ internal class NotificationGenerationProcessor( try { val notificationWillDisplayEvent = NotificationWillDisplayEvent(notificationJob.notification) withTimeout(30000L) { - launchOnIO { + GlobalScope.launch(Dispatchers.IO) { _lifecycleService.externalNotificationWillShowInForeground(notificationWillDisplayEvent) if (notificationWillDisplayEvent.discard) { diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationWorkManager.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationWorkManager.kt index 0dc570df7e..741b644fbb 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationWorkManager.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationWorkManager.kt @@ -64,7 +64,6 @@ internal class NotificationGenerationWorkManager : INotificationGenerationWorkMa class NotificationGenerationWorker(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result { if (!OneSignal.initWithContext(applicationContext)) { - Logging.warn("NotificationWorker skipped due to failed OneSignal initialization") return Result.success() } @@ -72,16 +71,11 @@ internal class NotificationGenerationWorkManager : INotificationGenerationWorkMa val inputData = inputData val id = inputData.getString(OS_ID_DATA_PARAM) ?: return Result.failure() - return try { + try { Logging.debug("NotificationWorker running doWork with data: $inputData") - val androidNotificationId = inputData.getInt(ANDROID_NOTIF_ID_WORKER_DATA_PARAM, 0) val jsonPayload = JSONObject(inputData.getString(JSON_PAYLOAD_WORKER_DATA_PARAM)) - val timestamp = - inputData.getLong( - TIMESTAMP_WORKER_DATA_PARAM, - System.currentTimeMillis() / 1000L, - ) + val timestamp = inputData.getLong(TIMESTAMP_WORKER_DATA_PARAM, System.currentTimeMillis() / 1000L) val isRestoring = inputData.getBoolean(IS_RESTORING_WORKER_DATA_PARAM, false) notificationProcessor.processNotificationData( @@ -91,13 +85,13 @@ internal class NotificationGenerationWorkManager : INotificationGenerationWorkMa isRestoring, timestamp, ) - Result.success() } catch (e: JSONException) { Logging.error("Error occurred doing work for job with id: $id", e) - Result.failure() + return Result.failure() } finally { removeNotificationIdProcessed(id!!) } + return Result.success() } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/lifecycle/impl/NotificationLifecycleService.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/lifecycle/impl/NotificationLifecycleService.kt index af5708fe11..ff85db76dc 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/lifecycle/impl/NotificationLifecycleService.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/lifecycle/impl/NotificationLifecycleService.kt @@ -7,7 +7,7 @@ import com.onesignal.common.JSONUtils import com.onesignal.common.events.CallbackProducer import com.onesignal.common.events.EventProducer import com.onesignal.common.exceptions.BackendException -import com.onesignal.common.threading.suspendifyWithErrorHandling +import com.onesignal.common.threading.OSPrimaryCoroutineScope import com.onesignal.core.internal.application.AppEntryAction import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModelStore @@ -33,8 +33,6 @@ import com.onesignal.notifications.internal.lifecycle.INotificationLifecycleServ import com.onesignal.notifications.internal.receivereceipt.IReceiveReceiptWorkManager import com.onesignal.session.internal.influence.IInfluenceManager import com.onesignal.user.internal.subscriptions.ISubscriptionManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -141,25 +139,18 @@ internal class NotificationLifecycleService( postedOpenedNotifIds.add(notificationId) - suspendifyWithErrorHandling( - useIO = true, - // or false for CPU operations - block = { + OSPrimaryCoroutineScope.execute { + try { _backend.updateNotificationAsOpened( appId, notificationId, subscriptionId, deviceType, ) - }, - onError = { ex -> - if (ex is BackendException) { - Logging.error("Notification opened confirmation failed with statusCode: ${ex.statusCode} response: ${ex.response}") - } else { - Logging.error("Unexpected error in notification opened confirmation", ex) - } - }, - ) + } catch (ex: BackendException) { + Logging.error("Notification opened confirmation failed with statusCode: ${ex.statusCode} response: ${ex.response}") + } + } } val openResult = NotificationHelper.generateNotificationOpenedResult(data, _time) @@ -275,9 +266,7 @@ internal class NotificationLifecycleService( val intent = intentGenerator.getIntentVisible() if (intent != null) { Logging.info("SDK running startActivity with Intent: $intent") - withContext(Dispatchers.Main) { - activity.startActivity(intent) - } + activity.startActivity(intent) } else { Logging.info("SDK not showing an Activity automatically due to it's settings.") } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/listeners/DeviceRegistrationListener.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/listeners/DeviceRegistrationListener.kt index 8044e6a083..abb7f5630e 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/listeners/DeviceRegistrationListener.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/listeners/DeviceRegistrationListener.kt @@ -3,7 +3,7 @@ package com.onesignal.notifications.internal.listeners import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.common.modeling.ModelChangedArgs -import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.startup.IStartableService @@ -67,7 +67,7 @@ internal class DeviceRegistrationListener( private fun retrievePushTokenAndUpdateSubscription() { val pushSubscription = _subscriptionManager.subscriptions.push - suspendifyOnIO { + suspendifyOnThread { val pushTokenAndStatus = _pushTokenManager.retrievePushToken() val permission = _notificationsManager.permission _subscriptionManager.addOrUpdatePushSubscriptionToken( @@ -88,7 +88,7 @@ internal class DeviceRegistrationListener( // when setting optedIn=true and there aren't permissions, automatically drive // permission request. if (args.path == SubscriptionModel::optedIn.name && args.newValue == true && !_notificationsManager.permission) { - suspendifyOnIO { + suspendifyOnThread { _notificationsManager.requestPermission(true) } } 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 0edc44f4e4..59bc6459f4 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 @@ -33,7 +33,6 @@ import com.onesignal.common.AndroidUtils import com.onesignal.common.events.EventProducer import com.onesignal.common.threading.Waiter import com.onesignal.common.threading.WaiterWithValue -import com.onesignal.common.threading.launchOnIO import com.onesignal.core.internal.application.ApplicationLifecycleHandlerBase import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModelStore @@ -46,6 +45,9 @@ import com.onesignal.notifications.R import com.onesignal.notifications.internal.common.NotificationHelper import com.onesignal.notifications.internal.permissions.INotificationPermissionChangedHandler import com.onesignal.notifications.internal.permissions.INotificationPermissionController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.yield @@ -62,6 +64,7 @@ internal class NotificationPermissionController( private var pollingWaitInterval: Long private val events = EventProducer() private var enabled: Boolean + private val coroutineScope = CoroutineScope(newSingleThreadContext(name = "NotificationPermissionController")) override val canRequestPermission: Boolean get() = @@ -76,7 +79,7 @@ internal class NotificationPermissionController( _requestPermission.registerAsCallback(PERMISSION_TYPE, this) pollingWaitInterval = _configModelStore.model.backgroundFetchNotificationPermissionInterval registerPollingLifecycleListener() - launchOnIO { + coroutineScope.launch { pollForPermission() } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/pushtoken/PushTokenManager.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/pushtoken/PushTokenManager.kt index 4dffeec5c5..33e66b4838 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/pushtoken/PushTokenManager.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/pushtoken/PushTokenManager.kt @@ -41,7 +41,7 @@ internal class PushTokenManager( ( pushTokenStatus == SubscriptionStatus.NO_PERMISSION || pushStatusRuntimeError(pushTokenStatus) - ) + ) ) { pushTokenStatus = registerResult.status } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/receivereceipt/impl/ReceiveReceiptWorkManager.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/receivereceipt/impl/ReceiveReceiptWorkManager.kt index a888205f7f..eb421f0ed8 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/receivereceipt/impl/ReceiveReceiptWorkManager.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/receivereceipt/impl/ReceiveReceiptWorkManager.kt @@ -74,13 +74,12 @@ internal class ReceiveReceiptWorkManager( class ReceiveReceiptWorker(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result { if (!OneSignal.initWithContext(applicationContext)) { - Logging.warn("ReceiveReceiptWorker skipped due to failed OneSignal initialization") return Result.success() } - val notificationId = inputData.getString(OS_NOTIFICATION_ID) ?: return Result.failure() - val appId = inputData.getString(OS_APP_ID) ?: return Result.failure() - val subscriptionId = inputData.getString(OS_SUBSCRIPTION_ID) ?: return Result.failure() + val notificationId = inputData.getString(OS_NOTIFICATION_ID)!! + val appId = inputData.getString(OS_APP_ID)!! + val subscriptionId = inputData.getString(OS_SUBSCRIPTION_ID)!! val receiveReceiptProcessor = OneSignal.getService() receiveReceiptProcessor.sendReceiveReceipt(appId, subscriptionId, notificationId) diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt index 1f91f2b4c7..bb2b567850 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt @@ -6,7 +6,6 @@ import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkerParameters import com.onesignal.OneSignal -import com.onesignal.debug.internal.logging.Logging import com.onesignal.notifications.internal.common.NotificationHelper import com.onesignal.notifications.internal.common.OSWorkManagerHelper import com.onesignal.notifications.internal.restoration.INotificationRestoreProcessor @@ -49,18 +48,16 @@ internal class NotificationRestoreWorkManager : INotificationRestoreWorkManager override suspend fun doWork(): Result { val context = applicationContext - val initialized = OneSignal.initWithContext(context) - if (!initialized) { - Logging.warn("NotificationRestoreWorker skipped due to failed OneSignal init") + if (!OneSignal.initWithContext(context)) { return Result.success() } if (!NotificationHelper.areNotificationsEnabled(context)) { - Logging.debug("NotificationRestoreWorker failed: Notifications disabled") return Result.failure() } val processor = OneSignal.getService() + processor.process() return Result.success() diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/BootUpReceiver.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/BootUpReceiver.kt index 22a601d172..9bb41d3de0 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/BootUpReceiver.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/BootUpReceiver.kt @@ -30,8 +30,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.onesignal.OneSignal -import com.onesignal.common.threading.suspendifyOnIO -import com.onesignal.debug.internal.logging.Logging import com.onesignal.notifications.internal.restoration.INotificationRestoreWorkManager class BootUpReceiver : BroadcastReceiver() { @@ -39,18 +37,12 @@ class BootUpReceiver : BroadcastReceiver() { context: Context, intent: Intent, ) { - val pendingResult = goAsync() - // in background, init onesignal and begin enqueueing restore work - suspendifyOnIO { - if (!OneSignal.initWithContext(context.applicationContext)) { - Logging.warn("NotificationRestoreReceiver skipped due to failed OneSignal init") - pendingResult.finish() - return@suspendifyOnIO - } - - val restoreWorkManager = OneSignal.getService() - restoreWorkManager.beginEnqueueingWork(context, true) - pendingResult.finish() + if (!OneSignal.initWithContext(context.applicationContext)) { + return } + + val restoreWorkManager = OneSignal.getService() + + restoreWorkManager.beginEnqueueingWork(context, true) } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/FCMBroadcastReceiver.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/FCMBroadcastReceiver.kt index c117dac793..bdef09d516 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/FCMBroadcastReceiver.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/FCMBroadcastReceiver.kt @@ -5,8 +5,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.onesignal.OneSignal -import com.onesignal.common.threading.suspendifyOnIO -import com.onesignal.debug.internal.logging.Logging import com.onesignal.notifications.internal.bundle.INotificationBundleProcessor // This is the entry point when a FCM payload is received from the Google Play services app @@ -25,35 +23,26 @@ class FCMBroadcastReceiver : BroadcastReceiver() { return } - val pendingResult = goAsync() - // process in background - suspendifyOnIO { - if (!OneSignal.initWithContext(context.applicationContext)) { - Logging.warn("FCMBroadcastReceiver skipped due to failed OneSignal init") - pendingResult.finish() - return@suspendifyOnIO - } - - val bundleProcessor = OneSignal.getService() + if (!OneSignal.initWithContext(context.applicationContext)) { + return + } - if (!isFCMMessage(intent)) { - setSuccessfulResultCode() - pendingResult.finish() - return@suspendifyOnIO - } + val bundleProcessor = OneSignal.getService() - val processedResult = bundleProcessor.processBundleFromReceiver(context, bundle) + if (!isFCMMessage(intent)) { + setSuccessfulResultCode() + return + } - // Prevent other FCM receivers from firing if work manager is processing the notification - if (processedResult?.isWorkManagerProcessing == true) { - setAbort() - pendingResult.finish() - return@suspendifyOnIO - } + val processedResult = bundleProcessor.processBundleFromReceiver(context, bundle) - setSuccessfulResultCode() - pendingResult.finish() + // Prevent other FCM receivers from firing if work manager is processing the notification + if (processedResult!!.isWorkManagerProcessing) { + setAbort() + return } + + setSuccessfulResultCode() } private fun setSuccessfulResultCode() { diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/NotificationDismissReceiver.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/NotificationDismissReceiver.kt index c16720874e..560d2b417a 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/NotificationDismissReceiver.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/NotificationDismissReceiver.kt @@ -28,33 +28,22 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.onesignal.OneSignal -import com.onesignal.common.threading.suspendifyOnIO -import com.onesignal.debug.internal.logging.Logging +import com.onesignal.common.threading.suspendifyBlocking import com.onesignal.notifications.internal.open.INotificationOpenedProcessor -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext class NotificationDismissReceiver : BroadcastReceiver() { override fun onReceive( context: Context, intent: Intent, ) { - val pendingResult = goAsync() - - suspendifyOnIO { - if (!OneSignal.initWithContext(context.applicationContext)) { - Logging.warn("NotificationOpenedReceiver skipped due to failed OneSignal init") - pendingResult.finish() - return@suspendifyOnIO - } + if (!OneSignal.initWithContext(context.applicationContext)) { + return + } - val notificationOpenedProcessor = OneSignal.getService() + var notificationOpenedProcessor = OneSignal.getService() - // init OneSignal in background but process in main - withContext(Dispatchers.Main) { - notificationOpenedProcessor.processFromContext(context, intent) - } - pendingResult.finish() + suspendifyBlocking { + notificationOpenedProcessor.processFromContext(context, intent) } } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/UpgradeReceiver.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/UpgradeReceiver.kt index 51572a658b..7fd768c9ae 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/UpgradeReceiver.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/UpgradeReceiver.kt @@ -31,8 +31,6 @@ import android.content.Context import android.content.Intent import android.os.Build import com.onesignal.OneSignal -import com.onesignal.common.threading.suspendifyOnIO -import com.onesignal.debug.internal.logging.Logging import com.onesignal.notifications.internal.restoration.INotificationRestoreWorkManager class UpgradeReceiver : BroadcastReceiver() { @@ -40,27 +38,19 @@ class UpgradeReceiver : BroadcastReceiver() { context: Context, intent: Intent, ) { - // TODO: Now that we aren't restoring like we used to, think we can remove this? - // I'll do some testing and look at the issue, but maybe someone has an answer or - // remembers what directly was causing this issue. + // TODO: Now that we arent restoring like we use to, think we can remove this? Ill do some + // testing and look at the issue but maybe someone has a answer or rems what directly + // was causing this issue // Return early if using Android 7.0 due to upgrade restore crash (#263) if (Build.VERSION.SDK_INT == Build.VERSION_CODES.N) { return } - val pendingResult = goAsync() - - // init OneSignal and enqueue restore work in background - suspendifyOnIO { - if (!OneSignal.initWithContext(context.applicationContext)) { - Logging.warn("UpgradeReceiver skipped due to failed OneSignal init") - pendingResult.finish() - return@suspendifyOnIO - } - - val restoreWorkManager = OneSignal.getService() - restoreWorkManager.beginEnqueueingWork(context, true) - pendingResult.finish() + if (!OneSignal.initWithContext(context.applicationContext)) { + return } + + val restoreWorkManager = OneSignal.getService() + restoreWorkManager.beginEnqueueingWork(context, true) } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandler.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandler.kt index cbf9a00141..cc8d9c2e2e 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandler.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandler.kt @@ -3,7 +3,7 @@ package com.onesignal.notifications.services import android.content.Intent import com.amazon.device.messaging.ADMMessageHandlerBase import com.onesignal.OneSignal -import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.debug.internal.logging.Logging import com.onesignal.notifications.internal.bundle.INotificationBundleProcessor import com.onesignal.notifications.internal.registration.impl.IPushRegistratorCallback @@ -13,24 +13,22 @@ import com.onesignal.notifications.internal.registration.impl.IPushRegistratorCa class ADMMessageHandler : ADMMessageHandlerBase("ADMMessageHandler") { override fun onMessage(intent: Intent) { val context = applicationContext - val bundle = intent.extras ?: return + if (!OneSignal.initWithContext(context)) { + return + } - suspendifyOnIO { - if (!OneSignal.initWithContext(context)) { - Logging.warn("onMessage skipped due to failed OneSignal init") - return@suspendifyOnIO - } + val bundle = intent.extras - val bundleProcessor = OneSignal.getService() - bundleProcessor.processBundleFromReceiver(context, bundle) - } + val bundleProcessor = OneSignal.getService() + + bundleProcessor.processBundleFromReceiver(context, bundle!!) } override fun onRegistered(newRegistrationId: String) { Logging.info("ADM registration ID: $newRegistrationId") - suspendifyOnIO { - val registerer = OneSignal.getService() + var registerer = OneSignal.getService() + suspendifyOnThread { registerer.fireCallback(newRegistrationId) } } @@ -44,8 +42,8 @@ class ADMMessageHandler : ADMMessageHandlerBase("ADMMessageHandler") { ) } - suspendifyOnIO { - val registerer = OneSignal.getService() + var registerer = OneSignal.getService() + suspendifyOnThread { registerer.fireCallback(null) } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandlerJob.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandlerJob.kt index 0eb1a06ca1..c707333743 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandlerJob.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandlerJob.kt @@ -4,7 +4,7 @@ import android.content.Context import android.content.Intent import com.amazon.device.messaging.ADMMessageHandlerJobBase import com.onesignal.OneSignal -import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.debug.internal.logging.Logging import com.onesignal.notifications.internal.bundle.INotificationBundleProcessor import com.onesignal.notifications.internal.registration.impl.IPushRegistratorCallback @@ -14,23 +14,17 @@ class ADMMessageHandlerJob : ADMMessageHandlerJobBase() { context: Context?, intent: Intent?, ) { - val bundle = intent?.extras - - if (context == null || bundle == null) { + if (context == null) { return } + if (!OneSignal.initWithContext(context.applicationContext)) { + return + } + val bundleProcessor = OneSignal.getService() - val safeContext = context.applicationContext - - suspendifyOnIO { - if (!OneSignal.initWithContext(safeContext)) { - Logging.warn("onMessage skipped due to failed OneSignal init") - return@suspendifyOnIO - } + val bundle = intent?.extras - val bundleProcessor = OneSignal.getService() - bundleProcessor.processBundleFromReceiver(safeContext, bundle) - } + bundleProcessor.processBundleFromReceiver(context!!, bundle!!) } override fun onRegistered( @@ -39,8 +33,8 @@ class ADMMessageHandlerJob : ADMMessageHandlerJobBase() { ) { Logging.info("ADM registration ID: $newRegistrationId") - suspendifyOnIO { - val registerer = OneSignal.getService() + var registerer = OneSignal.getService() + suspendifyOnThread { registerer.fireCallback(newRegistrationId) } } @@ -63,8 +57,8 @@ class ADMMessageHandlerJob : ADMMessageHandlerJobBase() { ) } - suspendifyOnIO { - val registerer = OneSignal.getService() + var registerer = OneSignal.getService() + suspendifyOnThread { registerer.fireCallback(null) } } diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt index d5b584331a..0731b597c6 100644 --- a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt @@ -3,7 +3,6 @@ package com.onesignal.notifications.internal.generation import android.content.Context import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest -import com.onesignal.common.threading.suspendifyOnIO import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.mocks.AndroidMockHelper @@ -22,7 +21,9 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.runs +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import org.json.JSONObject import org.robolectric.annotation.Config @@ -281,7 +282,7 @@ class NotificationGenerationProcessorTests : FunSpec({ coEvery { mocks.notificationLifecycleService.externalNotificationWillShowInForeground(any()) } coAnswers { val willDisplayEvent = firstArg() willDisplayEvent.preventDefault(false) - suspendifyOnIO { + GlobalScope.launch { delay(100) willDisplayEvent.preventDefault(true) delay(100) @@ -306,7 +307,7 @@ class NotificationGenerationProcessorTests : FunSpec({ coEvery { mocks.notificationLifecycleService.externalRemoteNotificationReceived(any()) } coAnswers { val receivedEvent = firstArg() receivedEvent.preventDefault(false) - suspendifyOnIO { + GlobalScope.launch { delay(100) receivedEvent.preventDefault(true) delay(100) diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/lifecycle/NotificationLifecycleServiceTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/lifecycle/NotificationLifecycleServiceTests.kt index 706370146f..3d18575f93 100644 --- a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/lifecycle/NotificationLifecycleServiceTests.kt +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/lifecycle/NotificationLifecycleServiceTests.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.withTimeout import org.json.JSONArray import org.json.JSONObject import org.robolectric.Robolectric +import org.robolectric.android.controller.ActivityController private class Mocks { val context = ApplicationProvider.getApplicationContext() @@ -75,8 +76,11 @@ private class Mocks { val activity: Activity = run { - val activityController = Robolectric.buildActivity(Activity::class.java) - activityController.setup() // Moves Activity to RESUMED state + val activityController: ActivityController + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activityController = controller + } activityController.get() } } diff --git a/README.md b/README.md index f1621c78bb..e726a811a1 100644 --- a/README.md +++ b/README.md @@ -39,4 +39,4 @@ For account issues and support please contact OneSignal support from the [OneSig To make things easier, we have published demo projects in the `/Examples` folder of this repository. #### Supports: -* Tested from Android 5.0 (API level 21) to Android 14 (34) \ No newline at end of file +* Tested from Android 5.0 (API level 21) to Android 14 (34)