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 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 + } + } +})