From cdec40ca8971dbca2f4085dfbf872b10615a5fa3 Mon Sep 17 00:00:00 2001 From: abdulraqeeb33 Date: Wed, 29 Oct 2025 12:42:37 -0400 Subject: [PATCH 1/7] improvement: Offloaded work on background threads. (#2394) * refactor: move initialization process off main thread * Release 5.4.0-alpha-01 * feat: Add Kotlin MainApplication and suspend initialization support (#2374) * feat: Add Kotlin MainApplication and suspend initialization support - Add MainApplicationKT.kt as Kotlin version of MainApplication.java - Add initWithContextSuspend() method for async initialization - Refactor OneSignalImp to use IO dispatcher internally for initialization - Add comprehensive unit tests for suspend initialization - Rename LatchAwaiter to CompletionAwaiter for better semantics - Add helper classes for user management (AppIdHelper, LoginHelper, LogoutHelper, UserSwitcher) - Update build.gradle to include Kotlin coroutines dependency - Ensure ANR prevention by using background threads for initialization * Added more tests * mandating passing app id in the login/logout methods * linting * Made app id mandatory for login and logout. * cleanup * reduce the forks * Time out, deprecate annotation and appid,context * ktlin * include MainApplication.java, locks, early returns * chore: Dispatcher Threads (#2375) * Using dispatcher * Update threads to 2 * Updated methods * linting * readme * using the same thread pool * lint * making sure initstate has the right value * lint * Clear state and skip performance tests * lint * clear preferences * fixing tests * fixing tests * fixing tests * fixing tests * fixing tests * addressed PR comments * Addressed comments and fixed tests * lint * lint * fix test * lint * rewrote the test * fix test * made the test more robust * clear all preferences and simplified mocks * added more robustness --------- Co-authored-by: AR Abdul Azeez * Fix OperationRepoTests CI/CD flakiness by using individual coVerify calls - Replace coVerifyOrder with individual coVerify(exactly = 1) calls - Makes tests more resilient to timing variations in CI/CD environments - Maintains verification of all critical operations while allowing flexibility in exact timing * remove try catch and lint * Addressed comments, removed global scope launches, broke down userswitcher * fixed flag * fix tests * making sure using the name instead of value * test isolation * logout test * comments * cleanup --------- Co-authored-by: AR Abdul Azeez * chore: bump SDK_VERSION to 5.4.0-alpha-02 (#2386) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Fix permissions activity (#2388) * broke down permissions activity * fixed the permissions dialog * cleanup * permissions tests * lint * waiting to false for pause * added some tests for pause * uncommented the code * removed duplicate class * fixed wrong import * fixed imports * fixed bug to display the dialog after cancelling * formatting ktlint * tests * lint * adding delay * removing the theme and going back to production * add a delay * add a delay --------- Co-authored-by: AR Abdul Azeez * chore: bump SDK_VERSION to 5.4.0-alpha-03 (#2392) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * spotless check * added more time * added more time --------- Co-authored-by: jinliu9508 Co-authored-by: AR Abdul Azeez Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> # Conflicts: # OneSignalSDK/build.gradle # OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt # Conflicts: # Examples/OneSignalDemo/gradle.properties # OneSignalSDK/gradle.properties # OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt # OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt # OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt # OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt # OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/services/SyncJobService.kt # OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt # OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt # OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt # OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt # OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt # OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpTests.kt # OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationProcessor.kt # OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt --- .../common/threading/CompletionAwaiter.kt | 135 +++++++ .../threading/CompletionAwaiterTests.kt | 363 ++++++++++++++++++ 2 files changed, 498 insertions(+) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CompletionAwaiter.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/CompletionAwaiterTests.kt 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 new file mode 100644 index 0000000000..880556393b --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CompletionAwaiter.kt @@ -0,0 +1,135 @@ +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/test/java/com/onesignal/common/threading/CompletionAwaiterTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/CompletionAwaiterTests.kt new file mode 100644 index 0000000000..37f239ead3 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/CompletionAwaiterTests.kt @@ -0,0 +1,363 @@ +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 + } + } +}) From e4b7e99fb1b1ef98f798b0cffa43e901a8627aec Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Fri, 21 Nov 2025 12:03:28 -0500 Subject: [PATCH 2/7] Merge pull request #2417 from OneSignal/tests/notification_test_speed tests: Much faster notification module tests From 5c245cd762fac6cde7e9948b082705c4608c01f9 Mon Sep 17 00:00:00 2001 From: jinliu Date: Wed, 26 Nov 2025 20:02:38 -0500 Subject: [PATCH 3/7] Merge pull request #2416 from OneSignal/add-test-in-app-message tests: more tests for InAppMessagesManager and LocationManager From d8d1afe3c320108d4c759c26044eaef535220050 Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Mon, 1 Dec 2025 16:21:58 -0500 Subject: [PATCH 4/7] Merge pull request #2419 from OneSignal/fix-flaky-operationrepo-execution-order fix: Rare User and Subscription creates and updates processing out of order (introduced in 5.4.0) From 514e30ae70710ed3beec51bac97b65c6c7834b21 Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Mon, 1 Dec 2025 16:22:49 -0500 Subject: [PATCH 5/7] Merge pull request #2497 from OneSignal/chore-tests_clean-up-delays-on-operation-repo-tests Chore: Tests: Clean up delays on operation repo tests From 2b78188ef2e7093731e7f1e31aa57409237ab290 Mon Sep 17 00:00:00 2001 From: jinliu Date: Mon, 1 Dec 2025 16:57:18 -0500 Subject: [PATCH 6/7] Merge pull request #2498 from OneSignal/chore-flaky-startupservice-test Chore: Tests: fix a flaky test in StartupServiceTests From 3eb98766e790938be035ad9f012bf5140bf239eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 6 Jan 2026 21:25:13 +0000 Subject: [PATCH 7/7] chore: bump SDK_VERSION to 5.5.0-beta --- 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 65a6974211..6526f5d808 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.2 +SDK_VERSION=5.5.0-beta \ No newline at end of file diff --git a/OneSignalSDK/gradle.properties b/OneSignalSDK/gradle.properties index 02d49e0009..c7680bac92 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.2 +SDK_VERSION=5.5.0-beta