diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml index 135fec9f63..b1badcf805 100644 --- a/.github/workflows/create-release-pr.yml +++ b/.github/workflows/create-release-pr.yml @@ -8,14 +8,14 @@ on: workflow_dispatch: inputs: version: - description: 'New SDK version (e.g. 5.1.38 or 5.2.0-beta1)' + description: "New SDK version (e.g. 5.1.38 or 5.2.0-beta1)" type: string required: true base_branch: - description: 'Target branch for the PR (e.g. main for regular releases, 5.4-main for 5.4.x releases)' + description: "Target branch for the PR (e.g. main for regular releases, 5.4-main for 5.4.x releases)" type: string required: false - default: 'main' + default: "main" permissions: contents: write @@ -64,14 +64,17 @@ jobs: fi - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: - fetch-depth: 0 # Ensure full history for git log - fetch-tags: true + fetch-depth: 0 # Ensure full history for git log + fetch-tags: true + + - name: Setup Git User + uses: OneSignal/sdk-actions/.github/actions/setup-git-user@main - name: Create release branch from base run: | - + if git ls-remote --exit-code --heads origin "$BRANCH"; then echo "Deleting remote branch $BRANCH" git push origin --delete "$BRANCH" @@ -87,9 +90,6 @@ jobs: - name: Commit and Push changes run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -am "chore: bump SDK_VERSION to $VERSION" git push origin "$BRANCH" @@ -183,4 +183,4 @@ jobs: --title "Release SDK v$VERSION" \ --body-file pr_body.md \ --head "$BRANCH" \ - --base "$BASE_BRANCH" \ No newline at end of file + --base "$BASE_BRANCH" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 6b7bd1894a..cb58cbc996 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -15,7 +15,7 @@ on: permissions: contents: write - pull-requests: read + pull-requests: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -24,6 +24,8 @@ concurrency: jobs: publish: runs-on: ubuntu-latest + outputs: + sdk_version: ${{ steps.extract_version.outputs.version }} env: MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} @@ -80,6 +82,7 @@ jobs: run: | VERSION=$(grep '^SDK_VERSION=' OneSignalSDK/gradle.properties | cut -d '=' -f2) echo "SDK_VERSION=$VERSION" >> $GITHUB_ENV + echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Resolved version: $VERSION" - name: Assemble Release @@ -174,3 +177,11 @@ jobs: --title "$VERSION" \ --notes-file release_notes.md \ $PRERELEASE + + wrapper_prs: + needs: publish + uses: OneSignal/sdk-actions/.github/workflows/create-wrapper-prs.yml@main + secrets: + GH_PUSH_TOKEN: ${{ secrets.GH_PUSH_TOKEN }} + with: + android_version: ${{ needs.publish.outputs.sdk_version }} diff --git a/OneSignalSDK/onesignal/core/build.gradle b/OneSignalSDK/onesignal/core/build.gradle index b0f2580711..708a90d912 100644 --- a/OneSignalSDK/onesignal/core/build.gradle +++ b/OneSignalSDK/onesignal/core/build.gradle @@ -33,7 +33,7 @@ android { } testOptions { unitTests.all { - maxParallelForks 1 + maxParallelForks Math.max(2, Runtime.runtime.availableProcessors().intdiv(2)) maxHeapSize '3072m' jvmArgs '-XX:MaxMetaspaceSize=256m', '-XX:+UseG1GC', '-XX:+UseStringDeduplication' } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AndroidUtils.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AndroidUtils.kt index b8a49e9551..fe846f503a 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AndroidUtils.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AndroidUtils.kt @@ -68,6 +68,9 @@ object AndroidUtils { return appVersion?.toString() } + // return Build.VERSION.SDK_INT; can be mocked to test specific functionalities under different SDK levels + val androidSDKInt: Int = Build.VERSION.SDK_INT + fun getManifestMeta( context: Context, metaName: String?, 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/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt index 19c7b1ddde..3b067820b1 100644 --- 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 @@ -25,8 +25,10 @@ import java.util.concurrent.atomic.AtomicInteger * - Small bounded queues (10 tasks) to prevent memory bloat * - Reduced context switching overhead * - Efficient thread management with controlled resource usage + * + * Made public to allow mocking in tests via IOMockHelper. */ -internal object OneSignalDispatchers { +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 @@ -35,7 +37,7 @@ internal object OneSignalDispatchers { 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 + 200 // Increased to handle more queued operations during init, while still preventing memory bloat 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 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..4439b688e3 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,6 @@ package com.onesignal.core.internal.operations.impl 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 +13,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 @@ -43,6 +45,14 @@ internal class OperationRepo( val previousWaitedTime: Long = 0, ) + // The order of operation execution is critical to this OperationRepo + // logic, all processing must be done on same thread to ensure this. + // - This result of not following this is flaky tests, which inturn could + // result in bugs in production. + private val scope by lazy { + CoroutineScope(newSingleThreadContext(name = "OSOperationRepoScope")) + } + private val executorsMap: Map internal val queue = mutableListOf() private val waiter = WaiterWithValue() @@ -92,7 +102,7 @@ internal class OperationRepo( override fun start() { paused = false - suspendifyOnIO { + scope.launch { // load saved operations first then start processing the queue to ensure correct operation order loadSavedOperations() processQueueForever() @@ -113,8 +123,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 { + scope.launch { internalEnqueue(OperationQueueItem(operation, bucket = enqueueIntoBucket), flush, true) } } @@ -127,7 +136,9 @@ internal class OperationRepo( operation.id = UUID.randomUUID().toString() val waiter = WaiterWithValue() - internalEnqueue(OperationQueueItem(operation, waiter, bucket = enqueueIntoBucket), flush, true) + scope.launch { + internalEnqueue(OperationQueueItem(operation, waiter, bucket = enqueueIntoBucket), flush, true) + } return waiter.waitForWake() } 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..055b269334 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 @@ -36,31 +36,51 @@ 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 - } + var reschedule = false - val backgroundService = OneSignal.getService() - backgroundService.runBackgroundServices() + try { + // Init OneSignal in background + if (!OneSignal.initWithContext(this)) { + return@suspendifyOnIO + } - Logging.debug("LollipopSyncRunnable:JobFinished needsJobReschedule: " + backgroundService.needsJobReschedule) + val backgroundService = OneSignal.getService() + backgroundService.runBackgroundServices() - // Reschedule if needed - val reschedule = backgroundService.needsJobReschedule - backgroundService.needsJobReschedule = false - jobFinished(jobParameters, reschedule) + Logging.debug("LollipopSyncRunnable:JobFinished needsJobReschedule: " + backgroundService.needsJobReschedule) + + // Reschedule if needed + reschedule = backgroundService.needsJobReschedule + backgroundService.needsJobReschedule = false + } finally { + // Always call jobFinished to finish the job; onStopJob will handle the case when init failed + jobFinished(jobParameters, reschedule) + } } + // Returning true means the job will always continue running and do everything else in IO thread + // When initWithContext failed, the background task will simply end return true } override fun onStopJob(jobParameters: JobParameters): Boolean { - // We assume init has been called via onStartJob - var backgroundService = OneSignal.getService() - val reschedule = backgroundService.cancelRunBackgroundServices() - Logging.debug("SyncJobService onStopJob called, system conditions not available reschedule: $reschedule") - return reschedule + /* + * After 5.4, onStartJob calls initWithContext in background. That introduced a small possibility + * when onStopJob is called before the initialization completes in the background. When that happens, + * OneSignal.getService will run into a NPE. In that case, we just need to omit the job and do not + * reschedule. + */ + + // Additional hardening in the event of getService failure + try { + // We assume init has been called via onStartJob\ + val backgroundService = OneSignal.getService() + val reschedule = backgroundService.cancelRunBackgroundServices() + Logging.debug("SyncJobService onStopJob called, system conditions not available reschedule: $reschedule") + return reschedule + } catch (e: Exception) { + Logging.error("SyncJobService onStopJob failed, omit and do not reschedule") + return false + } } } 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..7b6ee4041e 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 @@ -9,7 +9,6 @@ import com.onesignal.common.modules.IModule 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.core.CoreModule @@ -39,19 +38,16 @@ import com.onesignal.user.internal.identity.IdentityModelStore import com.onesignal.user.internal.properties.PropertiesModelStore import com.onesignal.user.internal.resolveAppId import com.onesignal.user.internal.subscriptions.SubscriptionModelStore +import kotlinx.coroutines.CompletableDeferred 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") + + private val suspendCompletion = CompletableDeferred() @Volatile private var initState: InitState = InitState.NOT_STARTED @@ -263,7 +259,6 @@ internal class OneSignalImp( suspendifyOnIO { internalInit(context, appId) } - initState = InitState.SUCCESS return true } @@ -306,22 +301,16 @@ internal class OneSignalImp( ) { Logging.log(LogLevel.DEBUG, "Calling deprecated login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)") - if (!initState.isSDKAccessible()) { - throw IllegalStateException("Must call 'initWithContext' before 'login'") - } + waitForInit(operationName = "login") - waitForInit() suspendifyOnIO { loginHelper.login(externalId, jwtBearerToken) } } override fun logout() { Logging.log(LogLevel.DEBUG, "Calling deprecated logout()") - if (!initState.isSDKAccessible()) { - throw IllegalStateException("Must call 'initWithContext' before 'logout'") - } + waitForInit(operationName = "logout") - waitForInit() suspendifyOnIO { logoutHelper.logout() } } @@ -333,10 +322,16 @@ internal class OneSignalImp( override fun getAllServices(c: Class): List = services.getAllServices(c) - private fun waitForInit() { - val completed = initAwaiter.await() - if (!completed) { - throw IllegalStateException("initWithContext was not called or timed out") + /** + * Blocking version that waits for initialization to complete. + * Uses runBlocking to bridge to the suspend implementation. + * Waits indefinitely until init completes and logs how long it took. + * + * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") + */ + private fun waitForInit(operationName: String? = null) { + runBlocking(ioDispatcher) { + waitUntilInitInternal(operationName) } } @@ -344,23 +339,65 @@ internal class OneSignalImp( * Notifies both blocking and suspend callers that initialization is complete */ private fun notifyInitComplete() { - initAwaiter.complete() + suspendCompletion.complete(Unit) } - private suspend fun suspendUntilInit() { + /** + * Suspend version that waits for initialization to complete. + * Waits indefinitely until init completes and logs how long it took. + * + * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") + */ + private suspend fun suspendUntilInit(operationName: String? = null) { + waitUntilInitInternal(operationName) + } + + /** + * Common implementation for waiting until initialization completes. + * Waits indefinitely until init completes (SUCCESS or FAILED) to ensure consistent state. + * Logs how long initialization took when it completes. + * + * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") + */ + private suspend fun waitUntilInitInternal(operationName: String? = null) { when (initState) { InitState.NOT_STARTED -> { - throw IllegalStateException("Must call 'initWithContext' before use") + val message = if (operationName != null) { + "Must call 'initWithContext' before '$operationName'" + } else { + "Must call 'initWithContext' before use" + } + throw IllegalStateException(message) } 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") + Logging.debug("Waiting for init to complete...") + + val startTime = System.currentTimeMillis() + + // Wait indefinitely until init actually completes - ensures consistent state + // Function only returns when initState is SUCCESS or FAILED + // NOTE: This is a suspend function, so it's non-blocking when called from coroutines. + // However, if waitForInit() (which uses runBlocking) is called from the main thread, + // it will block the main thread indefinitely until init completes, which can cause ANRs. + // This is intentional per PR #2412: "ANR is the lesser of two evils and the app can recover, + // where an uncaught throw it can not." To avoid ANRs, call SDK methods from background threads + // or use the suspend API from coroutines. + suspendCompletion.await() + + // Log how long initialization took + val elapsed = System.currentTimeMillis() - startTime + val message = if (operationName != null) { + "OneSignalImp initialization completed before '$operationName' (took ${elapsed}ms)" + } else { + "OneSignalImp initialization completed (took ${elapsed}ms)" + } + Logging.debug(message) + + // Re-check state after waiting - init might have failed during the wait + if (initState == InitState.FAILED) { + throw IllegalStateException("Initialization failed. Cannot proceed.") } + // initState is guaranteed to be SUCCESS here - consistent state } InitState.FAILED -> { throw IllegalStateException("Initialization failed. Cannot proceed.") @@ -377,23 +414,7 @@ internal class OneSignalImp( } 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() - } - InitState.FAILED -> { - throw IllegalStateException("Initialization failed. Cannot proceed.") - } - else -> { - // SUCCESS - waitForInit() - } - } - + waitForInit() return getter() } @@ -407,8 +428,9 @@ internal class OneSignalImp( // 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}") } + // Call suspendAndReturn directly to avoid nested runBlocking (waitAndReturn -> waitForInit -> runBlocking) return runBlocking(ioDispatcher) { - waitAndReturn(getter) + suspendAndReturn(getter) } } @@ -508,7 +530,8 @@ internal class OneSignalImp( ) = withContext(ioDispatcher) { Logging.log(LogLevel.DEBUG, "login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)") - suspendUntilInit() + suspendUntilInit(operationName = "login") + if (!isInitialized) { throw IllegalStateException("'initWithContext failed' before 'login'") } @@ -520,7 +543,7 @@ internal class OneSignalImp( withContext(ioDispatcher) { Logging.log(LogLevel.DEBUG, "logoutSuspend()") - suspendUntilInit() + suspendUntilInit(operationName = "logout") if (!isInitialized) { throw IllegalStateException("'initWithContext failed' before 'logout'") 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/core/internal/application/SDKInitSuspendTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt index 07fce3358c..462ea7a746 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt @@ -287,7 +287,7 @@ class SDKInitSuspendTests : FunSpec({ } // Should throw immediately because isInitialized is false - exception.message shouldBe "Must call 'initWithContext' before use" + exception.message shouldBe "Must call 'initWithContext' before 'login'" } } @@ -303,7 +303,7 @@ class SDKInitSuspendTests : FunSpec({ } // Should throw immediately because isInitialized is false - exception.message shouldBe "Must call 'initWithContext' before use" + exception.message shouldBe "Must call 'initWithContext' before 'logout'" } } }) 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 index 318f6cb1c1..013899f8c8 100644 --- 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 @@ -5,7 +5,6 @@ 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 @@ -15,12 +14,27 @@ 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 +import java.util.concurrent.CountDownLatch @RobolectricTest class SDKInitTests : FunSpec({ + /** + * Helper function to wait for OneSignal initialization to complete. + * @param oneSignalImp The OneSignalImp instance to wait for + * @param maxAttempts Maximum number of attempts (default: 100) + * @param sleepMs Sleep duration between attempts in milliseconds (default: 20) + */ + fun waitForInitialization(oneSignalImp: OneSignalImp, maxAttempts: Int = 100, sleepMs: Long = 20) { + var attempts = 0 + while (!oneSignalImp.isInitialized && attempts < maxAttempts) { + Thread.sleep(sleepMs) + attempts++ + } + oneSignalImp.isInitialized shouldBe true + } + beforeAny { Logging.logLevel = LogLevel.NONE @@ -89,9 +103,9 @@ class SDKInitTests : FunSpec({ test("initWithContext with no appId succeeds when configModel has appId") { // Given // block SharedPreference before calling init - val trigger = CompletionAwaiter("Test") + val trigger = CountDownLatch(1) val context = getApplicationContext() - val blockingPrefContext = BlockingPrefsContext(context, trigger, 2000) + val blockingPrefContext = BlockingPrefsContext(context, trigger) val os = OneSignalImp() var initSuccess = true @@ -122,7 +136,7 @@ class SDKInitTests : FunSpec({ accessorThread.isAlive shouldBe true // release SharedPreferences - trigger.complete() + trigger.countDown() accessorThread.join(500) accessorThread.isAlive shouldBe false @@ -135,9 +149,9 @@ class SDKInitTests : FunSpec({ test("initWithContext with appId does not block") { // Given // block SharedPreference before calling init - val trigger = CompletionAwaiter("Test") + val trigger = CountDownLatch(1) val context = getApplicationContext() - val blockingPrefContext = BlockingPrefsContext(context, trigger, 1000) + val blockingPrefContext = BlockingPrefsContext(context, trigger) val os = OneSignalImp() // When @@ -150,17 +164,22 @@ class SDKInitTests : FunSpec({ accessorThread.join(500) // Then - // should complete even SharedPreferences is unavailable + // should complete even SharedPreferences is unavailable (non-blocking) accessorThread.isAlive shouldBe false - os.isInitialized shouldBe true + + // Release the SharedPreferences lock so internalInit can complete + trigger.countDown() + + // Wait for initialization to complete (internalInit runs asynchronously) + waitForInitialization(os, maxAttempts = 50) } test("accessors will be blocked if call too early after initWithContext with appId") { // Given // block SharedPreference before calling init - val trigger = CompletionAwaiter("Test") + val trigger = CountDownLatch(1) val context = getApplicationContext() - val blockingPrefContext = BlockingPrefsContext(context, trigger, 2000) + val blockingPrefContext = BlockingPrefsContext(context, trigger) val os = OneSignalImp() val accessorThread = @@ -175,7 +194,7 @@ class SDKInitTests : FunSpec({ accessorThread.isAlive shouldBe true // release the lock on SharedPreferences - trigger.complete() + trigger.countDown() accessorThread.join(1000) accessorThread.isAlive shouldBe false @@ -202,9 +221,9 @@ class SDKInitTests : FunSpec({ test("ensure login called right after initWithContext can set externalId correctly") { // Given // block SharedPreference before calling init - val trigger = CompletionAwaiter("Test") + val trigger = CountDownLatch(1) val context = getApplicationContext() - val blockingPrefContext = BlockingPrefsContext(context, trigger, 2000) + val blockingPrefContext = BlockingPrefsContext(context, trigger) val os = OneSignalImp() val externalId = "testUser" @@ -224,11 +243,22 @@ class SDKInitTests : FunSpec({ accessorThread.start() accessorThread.join(500) - os.isInitialized shouldBe true + // initWithContext should return immediately (non-blocking) + // but isInitialized won't be true until internalInit completes + // which requires SharedPreferences to be unblocked accessorThread.isAlive shouldBe true - // release the lock on SharedPreferences - trigger.complete() + // release the lock on SharedPreferences so internalInit can complete + trigger.countDown() + + // Wait for initialization to complete (internalInit runs asynchronously) + var initAttempts = 0 + while (!os.isInitialized && initAttempts < 50) { + Thread.sleep(20) + initAttempts++ + } + + os.isInitialized shouldBe true accessorThread.join(500) accessorThread.isAlive shouldBe false @@ -307,12 +337,7 @@ class SDKInitTests : FunSpec({ 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 + waitForInitialization(os) // Give additional time for coroutines to settle, especially in CI/CD Thread.sleep(50) @@ -323,12 +348,7 @@ class SDKInitTests : FunSpec({ os.initWithContext(context) // Wait for second initialization to complete - attempts = 0 - while (!os.isInitialized && attempts < 100) { - Thread.sleep(20) - attempts++ - } - os.isInitialized shouldBe true + waitForInitialization(os) // Give additional time for coroutines to settle after second init Thread.sleep(50) @@ -437,20 +457,13 @@ class SDKInitTests : FunSpec({ */ class BlockingPrefsContext( context: Context, - private val unblockTrigger: CompletionAwaiter, - private val timeoutInMillis: Long, + private val unblockTrigger: CountDownLatch, ) : ContextWrapper(context) { override fun getSharedPreferences( name: String, mode: Int, ): SharedPreferences { - try { - unblockTrigger.await(timeoutInMillis) - } catch (e: InterruptedException) { - throw e - } catch (e: TimeoutCancellationException) { - throw e - } + unblockTrigger.await() 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..164949612c 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 @@ -31,6 +31,7 @@ import io.mockk.spyk import io.mockk.verify import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONArray @@ -72,6 +73,7 @@ private class Mocks { Time(), getNewRecordState(configModelStore), ), + recordPrivateCalls = true, ) } } @@ -108,9 +110,9 @@ class OperationRepoTests : FunSpec({ cachedOperation.id = UUID.randomUUID().toString() newOperation.id = UUID.randomUUID().toString() + val storeWaiter = Waiter() every { operationModelStore.create(any()) } answers { - // simulate a prolonged loading from cache - Thread.sleep(1000) + runBlocking { storeWaiter.waitForWake() } cachedOperation } @@ -131,16 +133,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.join() + storeWaiter.wake() // after loading is completed, the cached operation should be at the beginning of the queue backgroundThread.join() @@ -150,7 +144,8 @@ class OperationRepoTests : FunSpec({ test("containsInstanceOf") { // Given - val operationRepo = Mocks().operationRepo + val mocks = Mocks() + val operationRepo = mocks.operationRepo open class MyOperation : Operation("MyOp") { override val createComparisonKey = "" @@ -165,13 +160,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++ - } + mocks.waitForInternalEnqueue() // Then operationRepo.containsInstanceOf() shouldBe true @@ -182,13 +171,16 @@ class OperationRepoTests : FunSpec({ test("ensure processQueueForever suspends when queue is empty") { // Given val mocks = Mocks() + mocks.configModelStore.model.opRepoExecutionInterval = 10 // When mocks.operationRepo.start() val response = mocks.operationRepo.enqueueAndWait(mockOperation()) - // Must wait for background logic to spin to see how many times it - // will call getNextOps() - delay(1_000) + // KEEP: This delay must be kept as the implementation can change + // and this is the most reliable way to ensure waiting is happening. + // If this test as written in another way it could be fragile and/or + // pass when it shouldn't. + delay(500) // Then response shouldBe true @@ -271,12 +263,15 @@ class OperationRepoTests : FunSpec({ val opRepo = mocks.operationRepo coEvery { mocks.executor.execute(any()) - } returns ExecutionResponse(ExecutionResult.FAIL_RETRY, retryAfterSeconds = 1) andThen ExecutionResponse(ExecutionResult.SUCCESS) + } returns + ExecutionResponse(ExecutionResult.FAIL_RETRY, retryAfterSeconds = 1) andThen + ExecutionResponse(ExecutionResult.SUCCESS) // When opRepo.start() opRepo.enqueue(mockOperation()) - Thread.sleep(200) // Give time for the operation to be processed and retry delay to be set + mocks.waitForInternalEnqueue() + val response1 = withTimeoutOrNull(500) { opRepo.enqueueAndWait(mockOperation()) @@ -504,8 +499,7 @@ class OperationRepoTests : FunSpec({ mocks.operationRepo.enqueue(mockOperation()) val response = withTimeoutOrNull(100) { - val value = mocks.operationRepo.enqueueAndWait(mockOperation(), flush = true) - value + mocks.operationRepo.enqueueAndWait(mockOperation(), flush = true) } response shouldBe true } @@ -571,24 +565,17 @@ class OperationRepoTests : FunSpec({ test("starting OperationModelStore should be processed, following normal delay rules") { // Given val mocks = Mocks() - mocks.configModelStore.model.opRepoExecutionInterval = 200 - every { mocks.operationModelStore.list() } returns listOf(mockOperation()) - val executeOperationsCall = mockExecuteOperations(mocks.operationRepo) + val operations = listOf(mockOperation()) + every { mocks.operationModelStore.list() } returns operations // When mocks.operationRepo.start() - val immediateResult = - withTimeoutOrNull(200) { - executeOperationsCall.waitForWake() - } - val delayedResult = - withTimeoutOrNull(200) { - executeOperationsCall.waitForWake() - } + mocks.operationRepo.enqueueAndWait(mockOperationNonGroupable()) - // Then - with parallel execution, timing may vary, so we just verify the operation eventually executes - val result = immediateResult ?: delayedResult - result shouldBe true + coVerifyOrder { + mocks.operationRepo["waitForNewOperationAndExecutionInterval"]() + mocks.executor.execute(operations) + } } test("ensure results from executeOperations are added to beginning of the queue") { @@ -630,7 +617,6 @@ class OperationRepoTests : FunSpec({ test("execution of an operation with translation IDs delays follow up operations") { // Given 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") @@ -684,7 +670,6 @@ class OperationRepoTests : FunSpec({ test("execution of an operation with translation IDs removes the operation from queue before delay") { // Given val mocks = Mocks() - mocks.configModelStore.model.opRepoPostCreateDelay = 100 val operation = mockOperation(groupComparisonType = GroupComparisonType.NONE) val opId = operation.id val idTranslation = mapOf("local-id1" to "id1") @@ -751,14 +736,8 @@ class OperationRepoTests : FunSpec({ 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++ - } - // When + mocks.waitForInternalEnqueue() mocks.operationRepo.loadSavedOperations() // Then @@ -797,7 +776,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 + mocks.waitForInternalEnqueue() val response1 = withTimeoutOrNull(999) { opRepo.enqueueAndWait(mockOperation()) @@ -820,7 +799,6 @@ class OperationRepoTests : FunSpec({ 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() @@ -833,13 +811,11 @@ class OperationRepoTests : FunSpec({ // 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 @@ -876,23 +852,6 @@ class OperationRepoTests : FunSpec({ 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) @@ -964,10 +923,17 @@ class OperationRepoTests : FunSpec({ val executeWaiter = WaiterWithValue() coEvery { opRepo.executeOperations(any()) } coAnswers { executeWaiter.wake(true) - delay(10) firstArg>().forEach { it.waiter?.wake(true) } } return executeWaiter } } } + +private fun Mocks.waitForInternalEnqueue() { + verify(timeout = 100) { + operationRepo["internalEnqueue"]( + any(), any(), any(), any() + ) + } +} 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..7416b2910c 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 @@ -4,10 +4,12 @@ import com.onesignal.common.services.ServiceBuilder import com.onesignal.common.services.ServiceProvider import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging +import com.onesignal.mocks.IOMockHelper +import com.onesignal.mocks.IOMockHelper.awaitIO import io.kotest.assertions.throwables.shouldThrowUnit import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.comparables.shouldBeLessThan import io.kotest.matchers.shouldBe -import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.mockk import io.mockk.spyk @@ -26,6 +28,8 @@ class StartupServiceTests : FunSpec({ return serviceBuilder.build() } + listener(IOMockHelper) + beforeAny { Logging.logLevel = LogLevel.NONE } @@ -79,39 +83,55 @@ class StartupServiceTests : FunSpec({ test("startup will call all IStartableService dependencies successfully after a short delay") { // Given - val mockStartupService1 = spyk() - val mockStartupService2 = spyk() + val mockStartupService1 = mockk(relaxed = true) + val mockStartupService2 = mockk(relaxed = true) val startupService = StartupService(setupServiceProvider(listOf(), listOf(mockStartupService1, mockStartupService2))) // When startupService.scheduleStart() - // Then - Thread.sleep(10) + // Then - wait deterministically for both services to start using IOMockHelper + awaitIO() verify(exactly = 1) { mockStartupService1.start() } verify(exactly = 1) { mockStartupService2.start() } } test("scheduleStart does not block main thread") { // Given - val mockStartableService1 = spyk() + val mockStartableService1 = mockk(relaxed = true) val mockStartableService2 = spyk() val mockStartableService3 = spyk() - + // Only service1 and service2 are scheduled - service3 is NOT scheduled val startupService = StartupService(setupServiceProvider(listOf(), listOf(mockStartableService1, mockStartableService2))) - // When + // When - scheduleStart() is async, so it doesn't block + val startTime = System.currentTimeMillis() startupService.scheduleStart() + val scheduleTime = System.currentTimeMillis() - startTime + + // This should execute immediately since scheduleStart() doesn't block + // service3 is NOT part of scheduled services, so this is a direct call mockStartableService3.start() + val immediateTime = System.currentTimeMillis() - startTime - // Then - Thread.sleep(10) - coVerifyOrder { - // service3 will call start() first even though service1 and service2 are scheduled first - mockStartableService3.start() - mockStartableService1.start() - mockStartableService2.start() - } + // Then - verify scheduleStart() returned quickly (non-blocking) + // Should return in < 50ms (proving it doesn't wait for services to start) + scheduleTime shouldBeLessThan 50L + immediateTime shouldBeLessThan 50L + + // Verify service3 was called immediately (proving main thread wasn't blocked) + verify(exactly = 1) { mockStartableService3.start() } + + // Wait deterministically for async execution using IOMockHelper + awaitIO() + + // Verify scheduled services were called + verify(exactly = 1) { mockStartableService1.start() } + verify(exactly = 1) { mockStartableService2.start() } + + // The key assertion: scheduleStart() returned immediately without blocking, + // allowing service3.start() to be called synchronously before scheduled services + // complete. This proves scheduleStart() is non-blocking. } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/services/SyncJobServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/services/SyncJobServiceTests.kt new file mode 100644 index 0000000000..8a09f9b847 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/services/SyncJobServiceTests.kt @@ -0,0 +1,175 @@ +package com.onesignal.core.services + +import android.app.job.JobParameters +import com.onesignal.OneSignal +import com.onesignal.core.internal.background.IBackgroundManager +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.mocks.IOMockHelper +import com.onesignal.mocks.IOMockHelper.awaitIO +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.mockkObject +import io.mockk.spyk +import io.mockk.unmockkAll +import io.mockk.verify + +private class Mocks { + val syncJobService = spyk(SyncJobService(), recordPrivateCalls = true) + val jobParameters = mockk(relaxed = true) + val mockBackgroundManager = mockk(relaxed = true) +} + +class SyncJobServiceTests : FunSpec({ + lateinit var mocks: Mocks + + listener(IOMockHelper) + + beforeAny { + Logging.logLevel = LogLevel.NONE + mocks = Mocks() // fresh instance for each test + mockkObject(OneSignal) + every { OneSignal.getService() } returns mocks.mockBackgroundManager + } + + afterAny { + unmockkAll() + } + + test("onStartJob returns true when initWithContext fails") { + // Given + val syncJobService = mocks.syncJobService + val jobParameters = mocks.jobParameters + coEvery { OneSignal.initWithContext(any()) } returns false + + // When + val result = syncJobService.onStartJob(jobParameters) + + // Then + result shouldBe true + } + + test("onStartJob calls runBackgroundServices when initWithContext succeeds") { + // Given + val mockBackgroundManager = mocks.mockBackgroundManager + val syncJobService = mocks.syncJobService + val jobParameters = mocks.jobParameters + coEvery { OneSignal.initWithContext(any()) } returns true + every { mockBackgroundManager.needsJobReschedule } returns false + + // When + val result = syncJobService.onStartJob(jobParameters) + awaitIO() + + // Then + result shouldBe true + coVerify { mockBackgroundManager.runBackgroundServices() } + verify { syncJobService.jobFinished(jobParameters, false) } + } + + test("onStartJob calls jobFinished with false when initWithContext failed") { + // Given + val syncJobService = mocks.syncJobService + val jobParameters = mocks.jobParameters + coEvery { OneSignal.initWithContext(any()) } returns false + + // When + syncJobService.onStartJob(jobParameters) + + // Then + verify { syncJobService.jobFinished(jobParameters, false) } + } + + test("onStartJob calls jobFinished with false when needsJobReschedule is false") { + // Given + val syncJobService = mocks.syncJobService + val jobParameters = mocks.jobParameters + coEvery { OneSignal.initWithContext(any()) } returns true + every { mocks.mockBackgroundManager.needsJobReschedule } returns false + + // When + syncJobService.onStartJob(jobParameters) + awaitIO() + + // Then + verify { syncJobService.jobFinished(jobParameters, false) } + } + + test("onStartJob calls jobFinished with true when needsJobReschedule is true") { + // Given + val mockBackgroundManager = mocks.mockBackgroundManager + val syncJobService = mocks.syncJobService + val jobParameters = mocks.jobParameters + coEvery { OneSignal.initWithContext(any()) } returns true + every { mockBackgroundManager.needsJobReschedule } returns true + + // When + syncJobService.onStartJob(jobParameters) + awaitIO() + + // Then + verify { syncJobService.jobFinished(jobParameters, true) } + verify { mockBackgroundManager.needsJobReschedule = false } + } + + test("onStartJob resets needsJobReschedule to false after reading it") { + // Given + val mockBackgroundManager = mocks.mockBackgroundManager + val syncJobService = mocks.syncJobService + val jobParameters = mocks.jobParameters + coEvery { OneSignal.initWithContext(any()) } returns true + every { mockBackgroundManager.needsJobReschedule } returns true + + // When + syncJobService.onStartJob(jobParameters) + awaitIO() + + // Then + verify { mockBackgroundManager.needsJobReschedule = false } + } + + test("onStopJob returns false when OneSignal.getService throws") { + // Given + val syncJobService = mocks.syncJobService + val jobParameters = mocks.jobParameters + coEvery { OneSignal.getService() } throws NullPointerException() + + // When + val result = syncJobService.onStopJob(jobParameters) + + // Then + result shouldBe false + } + + test("onStopJob calls cancelRunBackgroundServices and returns its result") { + // Given + val mockBackgroundManager = mocks.mockBackgroundManager + val syncJobService = mocks.syncJobService + val jobParameters = mocks.jobParameters + every { mockBackgroundManager.cancelRunBackgroundServices() } returns true + + // When + val result = syncJobService.onStopJob(jobParameters) + + // Then + result shouldBe true + verify { mockBackgroundManager.cancelRunBackgroundServices() } + } + + test("onStopJob returns false when cancelRunBackgroundServices returns false") { + // Given + val mockBackgroundManager = mocks.mockBackgroundManager + every { mockBackgroundManager.cancelRunBackgroundServices() } returns false + + // When + val result = mocks.syncJobService.onStopJob(mocks.jobParameters) + + // Then + result shouldBe false + verify { mockBackgroundManager.cancelRunBackgroundServices() } + } +}) 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..d660fa2525 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 @@ -215,46 +215,42 @@ class OneSignalImpTests : FunSpec({ 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 + // waitForInit() would timeout after 30 seconds and log a warning (not throw) // 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 + // The timeout behavior is built into waitUntilInitInternal() + // which uses withTimeout() to wait for up to 30 seconds (or 4.8 seconds on main thread) + // before logging a warning and proceeding - // 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 + // NOTE: We don't test waiting indefinitely here because: + // 1. It would make tests hang forever + // 2. 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 + test("waitForInit waits indefinitely until init completes") { + // This test verifies that waitUntilInitInternal waits indefinitely + // until initialization completes (per PR #2412) // 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) + // We can verify the wait behavior by checking: + // 1. The suspendCompletion (CompletableDeferred) is properly initialized + // 2. The initState is NOT_STARTED (which would throw immediately) // 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" + // - waitForInit() would call waitUntilInitInternal() + // - waitUntilInitInternal() would check initState == NOT_STARTED and throw immediately + // - If initState was IN_PROGRESS, it would wait indefinitely using suspendCompletion.await() + // - waitForInit() throws for NOT_STARTED/FAILED states, waits indefinitely for IN_PROGRESS - // This test documents this behavior without actually waiting 30 seconds + // This test documents this behavior without actually waiting indefinitely } }) diff --git a/OneSignalSDK/onesignal/in-app-messages/build.gradle b/OneSignalSDK/onesignal/in-app-messages/build.gradle index 2e5b0e0ae3..3d289be904 100644 --- a/OneSignalSDK/onesignal/in-app-messages/build.gradle +++ b/OneSignalSDK/onesignal/in-app-messages/build.gradle @@ -28,7 +28,7 @@ android { } testOptions { unitTests.all { - maxParallelForks 1 + maxParallelForks Math.max(2, Runtime.runtime.availableProcessors().intdiv(2)) maxHeapSize '2048m' } unitTests { diff --git a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt index 16dce8e5a4..397ea43152 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt @@ -1,80 +1,1190 @@ package com.onesignal.inAppMessages.internal +import android.content.Context +import com.onesignal.common.AndroidUtils +import com.onesignal.common.consistency.IamFetchReadyCondition +import com.onesignal.common.consistency.RywData import com.onesignal.common.consistency.models.IConsistencyManager -import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.common.exceptions.BackendException +import com.onesignal.common.modeling.ModelChangedArgs +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.inAppMessages.IInAppMessageClickListener +import com.onesignal.inAppMessages.IInAppMessageLifecycleListener +import com.onesignal.inAppMessages.InAppMessageActionUrlType import com.onesignal.inAppMessages.internal.backend.IInAppBackendService +import com.onesignal.inAppMessages.internal.common.OneSignalChromeTab import com.onesignal.inAppMessages.internal.display.IInAppDisplayer import com.onesignal.inAppMessages.internal.lifecycle.IInAppLifecycleService import com.onesignal.inAppMessages.internal.preferences.IInAppPreferencesController +import com.onesignal.inAppMessages.internal.prompt.impl.InAppMessagePrompt import com.onesignal.inAppMessages.internal.repositories.IInAppRepository import com.onesignal.inAppMessages.internal.state.InAppStateService import com.onesignal.inAppMessages.internal.triggers.ITriggerController +import com.onesignal.inAppMessages.internal.triggers.TriggerModel +import com.onesignal.inAppMessages.internal.triggers.TriggerModelStore +import com.onesignal.mocks.IOMockHelper +import com.onesignal.mocks.IOMockHelper.awaitIO import com.onesignal.mocks.MockHelper import com.onesignal.session.internal.influence.IInfluenceManager import com.onesignal.session.internal.outcomes.IOutcomeEventsController import com.onesignal.session.internal.session.ISessionService import com.onesignal.user.IUserManager 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 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.just import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.runs +import io.mockk.spyk +import io.mockk.unmockkObject import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.json.JSONArray +import org.json.JSONObject + +private class Mocks { + // mock default services needed for InAppMessagesManager + val applicationService = MockHelper.applicationService() + val sessionService = mockk(relaxed = true) + val influenceManager = mockk(relaxed = true) + val configModelStore = MockHelper.configModelStore() + val userManager = mockk(relaxed = true) + val identityModelStore = MockHelper.identityModelStore() + val pushSubscription = mockk(relaxed = true) + val outcomeEventsController = mockk(relaxed = true) + val inAppStateService = mockk(relaxed = true) + val inAppPreferencesController = mockk(relaxed = true) + val repository = mockk(relaxed = true) + val backend = mockk(relaxed = true) + val triggerController = mockk(relaxed = true) + val triggerModelStore = mockk(relaxed = true) + val inAppDisplayer = mockk(relaxed = true) + val inAppLifecycleService = mockk(relaxed = true) + val languageContext = MockHelper.languageContext() + val time = MockHelper.time(1000) + val inAppMessageLifecycleListener = spyk() + val inAppMessageClickListener = spyk() + val rywData = RywData("token", 100L) + + val rywDeferred = mockk> { + coEvery { await() } returns rywData + } + + val consistencyManager = mockk(relaxed = true) { + coEvery { getRywDataFromAwaitableCondition(any()) } returns rywDeferred + } + + val subscriptionManager = mockk(relaxed = true) { + every { subscriptions } returns mockk { + every { push } returns pushSubscription + } + } + + val testOutcome = + run { + val outcome = mockk(relaxed = true) + every { outcome.name } returns "outcome-name" + outcome + } + + val inAppMessageClickResult = + run { + val result = mockk(relaxed = true) + every { result.prompts } returns mutableListOf() + every { result.outcomes } returns mutableListOf(testOutcome) + every { result.tags } returns null + every { result.url } returns null + every { result.clickId } returns "click-id" + result + } + + // factory-style so every access returns a new message: + val testInAppMessage: InAppMessage + get() { + val json = JSONObject() + json.put("id", "test-message-id") + val variantsJson = JSONObject() + val allVariantJson = JSONObject() + allVariantJson.put("en", "variant-id-123") + variantsJson.put("all", allVariantJson) + json.put("variants", variantsJson) + json.put("triggers", JSONArray()) + return InAppMessage(json, time) + } + + // factory-style so every access returns a new message: + val testInAppMessagePreview: InAppMessage + get() = InAppMessage(true, time) + + // Helper function to create InAppMessagesManager with all dependencies + val inAppMessagesManager = InAppMessagesManager( + applicationService, + sessionService, + influenceManager, + configModelStore, + userManager, + identityModelStore, + subscriptionManager, + outcomeEventsController, + inAppStateService, + inAppPreferencesController, + repository, + backend, + triggerController, + triggerModelStore, + inAppDisplayer, + inAppLifecycleService, + languageContext, + time, + consistencyManager, + ) +} class InAppMessagesManagerTests : FunSpec({ - test("triggers are backed by the trigger model store") { - // Given - val mockTriggerModelStore = mockk() - val triggerModelSlots = mutableListOf() - every { mockTriggerModelStore.get(any()) } returns null - every { mockTriggerModelStore.add(capture(triggerModelSlots)) } answers {} - every { mockTriggerModelStore.remove(any()) } just runs - every { mockTriggerModelStore.clear() } just runs - - val iamManager = - InAppMessagesManager( - MockHelper.applicationService(), - mockk(), - mockk(), - mockk(), - mockk(), - MockHelper.identityModelStore(), - mockk(), - mockk(), - mockk(), - mockk(), - mockk(), - mockk(), - mockk(), - mockTriggerModelStore, - mockk(), - mockk(), - MockHelper.languageContext(), - MockHelper.time(1000), - mockk(), + lateinit var mocks: Mocks + + // register to access awaitIO() + listener(IOMockHelper) + + beforeAny { + Logging.logLevel = LogLevel.NONE + mocks = Mocks() // fresh instance for each test + } + + beforeSpec { + // required when testing functions that internally call suspendifyOnMain + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + afterSpec { + Dispatchers.resetMain() + } + + context("Trigger Management") { + test("triggers are backed by the trigger model store") { + // Given + val mockTriggerModelStore = mocks.triggerModelStore + val triggerModelSlots = mutableListOf() + val iamManager = mocks.inAppMessagesManager + every { mockTriggerModelStore.get(any()) } returns null + every { mockTriggerModelStore.add(capture(triggerModelSlots)) } answers {} + every { mockTriggerModelStore.remove(any()) } just runs + every { mockTriggerModelStore.clear() } just runs + + // When + iamManager.addTrigger("trigger-key1", "trigger-value1") + iamManager.addTriggers(mapOf("trigger-key2" to "trigger-value2", "trigger-key3" to "trigger-value3")) + iamManager.removeTrigger("trigger-key4") + iamManager.removeTriggers(listOf("trigger-key5", "trigger-key6")) + iamManager.clearTriggers() + + // Then + triggerModelSlots.map { it.key to it.value } shouldBe listOf( + "trigger-key1" to "trigger-value1", + "trigger-key2" to "trigger-value2", + "trigger-key3" to "trigger-value3", + ) + verify(exactly = 1) { + mockTriggerModelStore.remove("trigger-key4") + mockTriggerModelStore.remove("trigger-key5") + mockTriggerModelStore.remove("trigger-key6") + mockTriggerModelStore.clear() + } + } + + test("addTrigger updates existing trigger model when trigger already exists") { + // Given + val mockTriggerModelStore = mocks.triggerModelStore + val existingTrigger = TriggerModel().apply { + id = "existing-key" + key = "existing-key" + value = "old-value" + } + every { mockTriggerModelStore.get("existing-key") } returns existingTrigger + every { mockTriggerModelStore.add(any()) } just runs + + // When + mocks.inAppMessagesManager.addTrigger("existing-key", "new-value") + + // Then + existingTrigger.value shouldBe "new-value" + verify(exactly = 0) { mockTriggerModelStore.add(any()) } + } + + test("addTrigger creates new trigger model when trigger does not exist") { + // Given + val mockTriggerModelStore = mocks.triggerModelStore + val triggerModelSlots = mutableListOf() + every { mockTriggerModelStore.get("new-key") } returns null + every { mockTriggerModelStore.add(capture(triggerModelSlots)) } answers {} + + // When + mocks.inAppMessagesManager.addTrigger("new-key", "new-value") + + // Then + triggerModelSlots.size shouldBe 1 + with(triggerModelSlots[0]) { key to value } shouldBe ("new-key" to "new-value") + } + } + + context("Initialization and Start") { + test("start loads dismissed messages from preferences") { + // Given + val mockPrefs = mocks.inAppPreferencesController + val dismissedSet = setOf("dismissed-1", "dismissed-2") + val mockRepository = mocks.repository + every { mockPrefs.dismissedMessagesId } returns dismissedSet + every { mockPrefs.lastTimeInAppDismissed } returns null + coEvery { mockRepository.cleanCachedInAppMessages() } just runs + coEvery { mockRepository.listInAppMessages() } returns emptyList() + + // When + mocks.inAppMessagesManager.start() + + // Then + verify { mockPrefs.dismissedMessagesId } + coVerify { mockRepository.cleanCachedInAppMessages() } + } + + test("start loads last dismissal time from preferences") { + // Given + val mockPrefs = mocks.inAppPreferencesController + val mockState = mocks.inAppStateService + val lastDismissalTime = 5000L + every { mockPrefs.dismissedMessagesId } returns null + every { mockPrefs.lastTimeInAppDismissed } returns lastDismissalTime + val mockRepository = mocks.repository + coEvery { mockRepository.cleanCachedInAppMessages() } just runs + coEvery { mockRepository.listInAppMessages() } returns emptyList() + + // When + mocks.inAppMessagesManager.start() + + // Then + verify { mockState.lastTimeInAppDismissed = lastDismissalTime } + } + + test("start loads redisplayed messages from repository and resets display flag") { + // Given + val message1 = mocks.testInAppMessage + val message2 = mocks.testInAppMessage + message1.isDisplayedInSession = true + message2.isDisplayedInSession = true + val mockRepository = mocks.repository + coEvery { mockRepository.cleanCachedInAppMessages() } just runs + coEvery { mockRepository.listInAppMessages() } returns listOf(message1, message2) + + // When + mocks.inAppMessagesManager.start() + awaitIO() + + // Then + message1.isDisplayedInSession shouldBe false + message2.isDisplayedInSession shouldBe false + } + + test("start subscribes to all required services") { + // Given + val mockRepository = mocks.repository + coEvery { mockRepository.cleanCachedInAppMessages() } just runs + coEvery { mockRepository.listInAppMessages() } returns emptyList() + + // When + mocks.inAppMessagesManager.start() + awaitIO() + + // Then + verify { mocks.subscriptionManager.subscribe(any()) } + verify { mocks.inAppLifecycleService.subscribe(any()) } + verify { mocks.triggerController.subscribe(any()) } + verify { mocks.sessionService.subscribe(any()) } + verify { mocks.applicationService.addApplicationLifecycleHandler(any()) } + } + } + + context("Paused Property") { + test("paused getter returns state paused value") { + // Given + every { mocks.inAppStateService.paused } returns true + + // When + val result = mocks.inAppMessagesManager.paused + + // Then + result shouldBe true + } + + test("setting paused to true does nothing when no message showing") { + // Given + val mockState = mocks.inAppStateService + val mockDisplayer = mocks.inAppDisplayer + val iamManager = mocks.inAppMessagesManager + every { mockState.paused } returns false + every { mocks.inAppStateService.inAppMessageIdShowing } returns null + + // When + iamManager.paused = true + + // Then + verify { mockState.paused = true } + coVerify(exactly = 0) { mockDisplayer.dismissCurrentInAppMessage() } + } + } + + context("Lifecycle Listeners") { + test("addLifecycleListener subscribes listener") { + // Given + val mockListener = mocks.inAppMessageLifecycleListener + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.addLifecycleListener(mockListener) + iamManager.onMessageWillDisplay(mocks.testInAppMessage) + + // Then + // Verify listener callback was called + verify { mockListener.onWillDisplay(any()) } + } + + test("removeLifecycleListener unsubscribes listener") { + // Given + val mockListener = mocks.inAppMessageLifecycleListener + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.addLifecycleListener(mockListener) + iamManager.removeLifecycleListener(mockListener) + iamManager.onMessageWillDisplay(mocks.testInAppMessage) + + // Then + // Listener should not be called after removal + verify(exactly = 0) { mockListener.onWillDisplay(any()) } + } + + test("addClickListener subscribes listener") { + // Given + val mockListener = mocks.inAppMessageClickListener + val message = mocks.testInAppMessage + val mockClickResult = mocks.inAppMessageClickResult + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.addClickListener(mockListener) + iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + awaitIO() + + // Then + // Verify listener callback was called + verify { mockListener.onClick(any()) } + } + + test("removeClickListener unsubscribes listener") { + // Given + val mockListener = mockk(relaxed = true) + val message = mocks.testInAppMessage + val mockClickResult = mocks.inAppMessageClickResult + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.addClickListener(mockListener) + iamManager.removeClickListener(mockListener) + iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + awaitIO() + + // Then + // Listener should not be called after removal + verify(exactly = 0) { mockListener.onClick(any()) } + } + } + + context("Config Model Changes") { + test("onModelUpdated fetches messages when appId property changes") { + // Given + val mockDeferred = mocks.rywDeferred + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + val args = ModelChangedArgs( + ConfigModel(), + ConfigModel::appId.name, + ConfigModel::appId.name, + "old-value", + "new-value", ) - // When - iamManager.addTrigger("trigger-key1", "trigger-value1") - iamManager.addTriggers(mapOf("trigger-key2" to "trigger-value2", "trigger-key3" to "trigger-value3")) - iamManager.removeTrigger("trigger-key4") - iamManager.removeTriggers(listOf("trigger-key5", "trigger-key6")) - iamManager.clearTriggers() - - // Then - triggerModelSlots[0].key shouldBe "trigger-key1" - triggerModelSlots[0].value shouldBe "trigger-value1" - triggerModelSlots[1].key shouldBe "trigger-key2" - triggerModelSlots[1].value shouldBe "trigger-value2" - triggerModelSlots[2].key shouldBe "trigger-key3" - triggerModelSlots[2].value shouldBe "trigger-value3" - - verify(exactly = 1) { mockTriggerModelStore.remove("trigger-key4") } - verify(exactly = 1) { mockTriggerModelStore.remove("trigger-key5") } - verify(exactly = 1) { mockTriggerModelStore.remove("trigger-key6") } - verify(exactly = 1) { mockTriggerModelStore.clear() } + // When + mocks.inAppMessagesManager.onModelUpdated(args, "tag") + awaitIO() + + // Then + // Should trigger fetchMessagesWhenConditionIsMet + coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("onModelUpdated does nothing when non-appId property changes") { + // Given + val args = ModelChangedArgs( + ConfigModel(), + "other-property", + "other-property", + "old-value", + "new-value", + ) + + // When + mocks.inAppMessagesManager.onModelUpdated(args, "tag") + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("onModelReplaced fetches messages") { + // Given + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + + // When + mocks.inAppMessagesManager.onModelReplaced(ConfigModel(), "tag") + awaitIO() + + // Then + coVerify { + mocks.backend.listInAppMessages(any(), any(), any(), any()) + } + } + } + + context("Subscription Changes") { + test("onSubscriptionChanged fetches messages when push subscription id changes") { + // Given + val mockDeferred = mocks.rywDeferred + val subscriptionModel = SubscriptionModel() + val args = ModelChangedArgs( + subscriptionModel, + SubscriptionModel::id.name, + SubscriptionModel::id.name, + "old-id", + "new-id", + ) + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + + // When + mocks.inAppMessagesManager.onSubscriptionChanged(mocks.pushSubscription, args) + awaitIO() + + // Then + coVerify { + mocks.backend.listInAppMessages(any(), any(), any(), any()) + } + } + + test("onSubscriptionChanged does nothing for non-push subscription") { + // Given + val iamManager = mocks.inAppMessagesManager + val mockSubscription = mockk() + val subscriptionModel = SubscriptionModel() + val args = ModelChangedArgs( + subscriptionModel, + SubscriptionModel::id.name, + SubscriptionModel::id.name, + "old-id", + "new-id", + ) + + // When + iamManager.onSubscriptionChanged(mockSubscription, args) + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("onSubscriptionChanged does nothing when id path does not match") { + // Given + val iamManager = mocks.inAppMessagesManager + val subscriptionModel = SubscriptionModel() + val args = ModelChangedArgs( + subscriptionModel, + "other-path", + "other-path", + "old-value", + "new-value", + ) + + // When + iamManager.onSubscriptionChanged(mocks.pushSubscription, args) + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("onSubscriptionAdded does not fetch") { + // Given + val iamManager = mocks.inAppMessagesManager + val mockSubscription = mockk() + + // When + iamManager.onSubscriptionAdded(mockSubscription) + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("onSubscriptionRemoved does not fetch") { + // Given + val iamManager = mocks.inAppMessagesManager + val mockSubscription = mockk() + + // When + iamManager.onSubscriptionRemoved(mockSubscription) + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + } + + context("Session Lifecycle") { + test("onSessionStarted resets redisplayed messages and fetches messages") { + // Given + val message1 = mocks.testInAppMessage + val message2 = mocks.testInAppMessage + val mockRywData = mocks.rywData + val mockDeferred = mocks.rywDeferred + val mockRepository = mocks.repository + + message1.isDisplayedInSession = true + message2.isDisplayedInSession = true + coEvery { mockRepository.listInAppMessages() } returns listOf(message1, message2) + every { mocks.userManager.onesignalId } returns "onesignal-id" + coEvery { mockDeferred.await() } returns mockRywData + coEvery { + mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) + } returns mockDeferred + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + + // When + mocks.inAppMessagesManager.start() + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // Then + // Verify messages were reset and backend was called + message1.isDisplayedInSession shouldBe false + message2.isDisplayedInSession shouldBe false + coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("onSessionActive does nothing") { + // Given + val iamManager = mocks.inAppMessagesManager + + // When/Then - should not throw + iamManager.onSessionActive() + + // Verified by no exception being thrown + } + + test("onSessionEnded does nothing") { + // Given + val iamManager = mocks.inAppMessagesManager + + // When/Then - should not throw + iamManager.onSessionEnded(10L) + + // Verified by no exception being thrown + } + } + + context("Message Lifecycle Callbacks") { + test("onMessageWillDisplay fires lifecycle callback when subscribers exist") { + // Given + mocks.inAppMessagesManager.addLifecycleListener(mocks.inAppMessageLifecycleListener) + + // When + mocks.inAppMessagesManager.onMessageWillDisplay(mocks.testInAppMessage) + awaitIO() + + // Then + // Verify callback was fired + verify { mocks.inAppMessageLifecycleListener.onWillDisplay(any()) } + } + + test("onMessageWillDisplay does nothing when no subscribers") { + // Given + + // When/Then - should not throw + mocks.inAppMessagesManager.onMessageWillDisplay(mocks.testInAppMessage) + + // Verified by no exception being thrown when no listeners are subscribed + } + + test("onMessageWasDisplayed sends impression for non-preview message") { + // Given + + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } just runs + + // When + mocks.inAppMessagesManager.onMessageWasDisplayed(mocks.testInAppMessage) + awaitIO() + + // Then + coVerify { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } + } + + test("onMessageWasDisplayed does not send impression for preview message") { + // Given + + // When + mocks.inAppMessagesManager.onMessageWasDisplayed(mocks.testInAppMessagePreview) + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } + } + + test("onMessageWasDisplayed does not send duplicate impressions") { + // Given + val message = mocks.testInAppMessage + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } just runs + + // When - send impression twice + mocks.inAppMessagesManager.onMessageWasDisplayed(message) + mocks.inAppMessagesManager.onMessageWasDisplayed(message) + awaitIO() + + // Then - should only send once + coVerify(exactly = 1) { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } + } + + test("onMessageWillDismiss fires lifecycle callback when subscribers exist") { + // Given + mocks.inAppMessagesManager.addLifecycleListener(mocks.inAppMessageLifecycleListener) + + // When + mocks.inAppMessagesManager.onMessageWillDismiss(mocks.testInAppMessage) + awaitIO() + + // Then + // Verify callback was fired + verify { mocks.inAppMessageLifecycleListener.onWillDismiss(any()) } + } + + test("onMessageWillDismiss does nothing when no subscribers") { + // Given + + // When/Then - should not throw + mocks.inAppMessagesManager.onMessageWillDismiss(mocks.testInAppMessage) + + // Verified by no exception being thrown when no listeners are subscribed + } + + test("onMessageWasDismissed calls messageWasDismissed") { + // Given + every { mocks.inAppStateService.inAppMessageIdShowing } returns null + + // When + mocks.inAppMessagesManager.onMessageWasDismissed(mocks.testInAppMessage) + awaitIO() + + // Then + verify { mocks.influenceManager.onInAppMessageDismissed() } + } + } + + context("Trigger Callbacks") { + test("onTriggerCompleted does nothing") { + // Given + val iamManager = mocks.inAppMessagesManager + + // When/Then - should not throw + iamManager.onTriggerCompleted("trigger-id") + + // Verified by no exception being thrown (method is a no-op) + } + + test("onTriggerConditionChanged makes redisplay messages available and re-evaluates") { + // Given + val message = mocks.testInAppMessage + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + + // Fetch messages first + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // When + mocks.inAppMessagesManager.onTriggerConditionChanged("trigger-id") + + // Then + // Should trigger re-evaluation + coVerify { mocks.triggerController.evaluateMessageTriggers(any()) } + } + + test("onTriggerChanged makes redisplay messages available and re-evaluates") { + // Given + val message = mocks.testInAppMessage + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + + // Fetch messages first + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // When + mocks.inAppMessagesManager.onTriggerChanged("trigger-key") + + // Then + // Should trigger re-evaluation + verify { mocks.triggerController.evaluateMessageTriggers(any()) } + } + } + + context("Application Lifecycle") { + test("onFocus does nothing") { + // Given + + // When/Then - should not throw + mocks.inAppMessagesManager.onFocus(false) + mocks.inAppMessagesManager.onFocus(true) + } + test("onUnfocused does nothing") { + // Given + + // When/Then - should not throw + mocks.inAppMessagesManager.onUnfocused() + + // Verified by no exception being thrown + } + } + + context("Message Action Handling") { + test("onMessageActionOccurredOnPreview processes preview actions") { + // Given + val mockClickListener = mockk(relaxed = true) + val mockPrompt = mockk(relaxed = true) + every { mockPrompt.hasPrompted() } returns false + coEvery { mockPrompt.handlePrompt() } returns InAppMessagePrompt.PromptActionResult.PERMISSION_GRANTED + every { mocks.inAppStateService.currentPrompt } returns null + mocks.inAppMessagesManager.addClickListener(mockClickListener) + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnPreview(mocks.testInAppMessagePreview, mocks.inAppMessageClickResult) + awaitIO() + + // Then + verify { mocks.inAppMessageClickResult.isFirstClick = any() } + } + + test("onMessagePageChanged sends page impression for non-preview message") { + // Given + val mockPage = mockk(relaxed = true) + every { mocks.pushSubscription.id } returns "subscription-id" + every { mockPage.pageId } returns "page-id" + coEvery { mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) } just runs + + // When + mocks.inAppMessagesManager.onMessagePageChanged(mocks.testInAppMessage, mockPage) + awaitIO() + + // Then + coVerify { mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) } + } + + test("onMessagePageChanged does nothing for preview message") { + // Given + val mockPage = mockk(relaxed = true) + + // When + mocks.inAppMessagesManager.onMessagePageChanged(mocks.testInAppMessagePreview, mockPage) + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) } + } + } + + context("Error Handling") { + test("onMessageWasDisplayed removes impression from set on backend failure") { + // Given + val message = mocks.testInAppMessage + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { + mocks.backend.sendIAMImpression(any(), any(), any(), any()) + } throws BackendException(500, "Server error") + + // When + mocks.inAppMessagesManager.onMessageWasDisplayed(message) + awaitIO() + + // Try again - should retry since impression was removed + mocks.inAppMessagesManager.onMessageWasDisplayed(message) + awaitIO() + + // Then - should attempt twice since first failed + coVerify(exactly = 2) { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } + } + + test("onMessagePageChanged removes page impression on backend failure") { + // Given + val message = mocks.testInAppMessage + val mockPage = mockk(relaxed = true) + every { mocks.pushSubscription.id } returns "subscription-id" + every { mockPage.pageId } returns "page-id" + coEvery { + mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) + } throws BackendException(500, "Server error") + + // When + mocks.inAppMessagesManager.onMessagePageChanged(message, mockPage) + awaitIO() + + // Try again - should retry since page impression was removed + mocks.inAppMessagesManager.onMessagePageChanged(message, mockPage) + awaitIO() + + // Then - should attempt twice since first failed + coVerify(exactly = 2) { mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) } + } + + test("onMessageActionOccurredOnMessage removes click on backend failure") { + // Given + val message = mocks.testInAppMessage + coEvery { + mocks.backend.sendIAMClick(any(), any(), any(), any(), any(), any()) + } throws BackendException(500, "Server error") + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(message, mocks.inAppMessageClickResult) + awaitIO() + + // Then + coVerify { mocks.backend.sendIAMClick(any(), any(), any(), any(), any(), any()) } + // Click should be removed from message on failure + message.isClickAvailable("click-id") shouldBe true + } + } + + context("Message Fetching") { + test("fetchMessagesWhenConditionIsMet returns early when app is not in foreground") { + // Given + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns false + + // When - trigger fetch via onSessionStarted + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("fetchMessagesWhenConditionIsMet returns early when subscriptionId is empty") { + // Given + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "" + + // When + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("fetchMessagesWhenConditionIsMet returns early when subscriptionId is local ID") { + // Given + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "local-123" + + // When + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("fetchMessagesWhenConditionIsMet evaluates messages when new messages are returned") { + // Given + val message = mocks.testInAppMessage + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + every { mocks.triggerController.evaluateMessageTriggers(any()) } returns false + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + + // When + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // Then + coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + verify { mocks.triggerController.evaluateMessageTriggers(any()) } + } + } + + context("Message Queue and Display") { + test("messages are not queued when paused") { + // Given + val message = mocks.testInAppMessage + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + every { mocks.triggerController.evaluateMessageTriggers(message) } returns true + every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false + every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false + every { mocks.inAppStateService.inAppMessageIdShowing } returns null + every { mocks.inAppStateService.paused } returns true + + // When - fetch messages while paused + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // Then - should not display + coVerify(exactly = 0) { mocks.inAppDisplayer.displayMessage(any()) } + } + } + + context("Message Evaluation") { + test("messages are evaluated and queued when paused is set to false") { + // Given + val message = mocks.testInAppMessage + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + every { mocks.triggerController.evaluateMessageTriggers(message) } returns true + every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false + every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false + every { mocks.inAppStateService.inAppMessageIdShowing } returns null + every { mocks.inAppStateService.paused } returns true + coEvery { mocks.applicationService.waitUntilSystemConditionsAvailable() } returns true + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + coEvery { mocks.inAppDisplayer.displayMessage(any()) } returns true + + // Fetch messages first + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // When - set paused to false, which triggers evaluateInAppMessages + mocks.inAppMessagesManager.paused = false + + // Then + verify { mocks.triggerController.evaluateMessageTriggers(message) } + } + + test("dismissed messages are not queued for display") { + // Given + val message = mocks.testInAppMessage + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + every { mocks.triggerController.evaluateMessageTriggers(message) } returns true + every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false + every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false + every { mocks.inAppStateService.paused } returns false + + // Fetch messages + mocks.inAppMessagesManager.onSessionStarted() + + // Dismiss the message + mocks.inAppMessagesManager.onMessageWasDismissed(message) + awaitIO() + + // When - trigger evaluation + mocks.inAppMessagesManager.paused = false + + // Then - should not display dismissed message + coVerify(exactly = 0) { mocks.inAppDisplayer.displayMessage(message) } + } + } + + context("Message Actions - Outcomes and Tags") { + test("onMessageActionOccurredOnMessage fires outcomes") { + // Given + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() + + // Then - wait for async operations + coVerify { mocks.outcomeEventsController.sendOutcomeEvent("outcome-name") } + } + + test("onMessageActionOccurredOnMessage fires outcomes with weight") { + // Given + val weight = 5.0f + every { mocks.testOutcome.weight } returns weight + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() + + // Then - wait for async operations + coVerify { mocks.outcomeEventsController.sendOutcomeEventWithValue("outcome-name", weight) } + } + + test("onMessageActionOccurredOnMessage adds tags") { + // Given + val mockTags = mockk(relaxed = true) + val tagsToAdd = JSONObject() + tagsToAdd.put("key1", "value1") + every { mockTags.tagsToAdd } returns tagsToAdd + every { mockTags.tagsToRemove } returns null + every { mocks.inAppMessageClickResult.tags } returns mockTags + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() + + // Then - wait for async operations + verify { mocks.userManager.addTags(any()) } + } + + test("onMessageActionOccurredOnMessage removes tags") { + // Given + val mockTags = mockk(relaxed = true) + val tagsToRemove = JSONArray() + tagsToRemove.put("key1") + every { mockTags.tagsToAdd } returns null + every { mockTags.tagsToRemove } returns tagsToRemove + every { mocks.inAppMessageClickResult.tags } returns mockTags + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() + + // Then - wait for async operations + coVerify { mocks.userManager.removeTags(any()) } + } + + test("onMessageActionOccurredOnMessage opens URL in browser") { + // Given + val url = "https://example.com" + val mockContext = mockk(relaxed = true) + every { mocks.applicationService.appContext } returns mockContext + every { mocks.inAppMessageClickResult.url } returns url + every { mocks.inAppMessageClickResult.urlTarget } returns InAppMessageActionUrlType.BROWSER + mockkObject(AndroidUtils) + every { AndroidUtils.openURLInBrowser(any(), any()) } just runs + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() + + // Then + coVerify { AndroidUtils.openURLInBrowser(any(), url) } + + unmockkObject(AndroidUtils) + } + + test("onMessageActionOccurredOnMessage opens URL in webview") { + // Given + val mockContext = mockk(relaxed = true) + every { mocks.applicationService.appContext } returns mockContext + every { mocks.inAppMessageClickResult.url } returns "https://example.com" + every { mocks.inAppMessageClickResult.urlTarget } returns InAppMessageActionUrlType.IN_APP_WEBVIEW + mockkObject(OneSignalChromeTab) + every { OneSignalChromeTab.open(any(), any(), any()) } returns true + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() + + // Then + coVerify { OneSignalChromeTab.open("https://example.com", true, any()) } + + unmockkObject(OneSignalChromeTab) + } + + test("onMessageActionOccurredOnMessage does nothing when URL is empty") { + // Given + + // When/Then - should not throw + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + } + } + context("Prompt Processing") { + test("onMessageActionOccurredOnMessage processes prompts") { + // Given + val mockPrompt = mockk(relaxed = true) + every { mocks.inAppMessageClickResult.prompts } returns mutableListOf(mockPrompt) + every { mockPrompt.hasPrompted() } returns false + every { mockPrompt.setPrompted(any()) } just runs + // currentPrompt starts as null, then gets set to the prompt during processing + var currentPrompt: InAppMessagePrompt? = null + every { mocks.inAppStateService.currentPrompt } answers { currentPrompt } + every { mocks.inAppStateService.currentPrompt = any() } answers { currentPrompt = firstArg() } + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() + + // Then + coVerify { mocks.inAppDisplayer.dismissCurrentInAppMessage() } + coVerify { mockPrompt.setPrompted(any()) } + } + + test("onMessageActionOccurredOnMessage does nothing when prompts list is empty") { + // Given + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.inAppDisplayer.dismissCurrentInAppMessage() } + } + } + + context("Message Persistence") { + test("onMessageWasDismissed persists message to repository") { + // Given + val message = mocks.testInAppMessage + coEvery { mocks.repository.saveInAppMessage(any()) } just runs + every { mocks.inAppStateService.lastTimeInAppDismissed } returns 500L + every { mocks.inAppStateService.currentPrompt } returns null + + // When + mocks.inAppMessagesManager.onMessageWasDismissed(message) + awaitIO() + + // Then + coVerify { mocks.repository.saveInAppMessage(message) } + message.isDisplayedInSession shouldBe true + message.isTriggerChanged shouldBe false + } } }) diff --git a/OneSignalSDK/onesignal/location/build.gradle b/OneSignalSDK/onesignal/location/build.gradle index a7ed57f127..9d9246ac5a 100644 --- a/OneSignalSDK/onesignal/location/build.gradle +++ b/OneSignalSDK/onesignal/location/build.gradle @@ -28,7 +28,7 @@ android { } testOptions { unitTests.all { - maxParallelForks 1 + maxParallelForks Math.max(2, Runtime.runtime.availableProcessors().intdiv(2)) maxHeapSize '2048m' } unitTests { 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..d6bff44e70 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 @@ -95,11 +95,12 @@ internal class LocationManager( _capturer.locationCoarse = true } - if (Build.VERSION.SDK_INT >= 29) { + val androidSDKInt = AndroidUtils.androidSDKInt + if (androidSDKInt >= 29) { hasBackgroundPermissionGranted = AndroidUtils.hasPermission(LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING, true, _applicationService) } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + if (androidSDKInt < Build.VERSION_CODES.M) { if (!hasFinePermissionGranted && !hasCoarsePermissionGranted) { // Permission missing on manifest Logging.error("Location permissions not added on AndroidManifest file < M") @@ -130,7 +131,7 @@ internal class LocationManager( // ACCESS_COARSE_LOCATION permission defined on Manifest, prompt for permission // If permission already given prompt will return positive, otherwise will prompt again or show settings requestPermission = LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING - } else if (Build.VERSION.SDK_INT >= 29 && permissionList.contains(LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING)) { + } else if (androidSDKInt >= 29 && permissionList.contains(LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING)) { // ACCESS_BACKGROUND_LOCATION permission defined on Manifest, prompt for permission requestPermission = LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING } @@ -151,7 +152,7 @@ internal class LocationManager( } else { hasCoarsePermissionGranted } - } else if (Build.VERSION.SDK_INT >= 29 && !hasBackgroundPermissionGranted) { + } else if (androidSDKInt >= 29 && !hasBackgroundPermissionGranted) { result = backgroundLocationPermissionLogic(true) } else { result = true diff --git a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/LocationManagerTests.kt b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/LocationManagerTests.kt new file mode 100644 index 0000000000..945b11b8eb --- /dev/null +++ b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/LocationManagerTests.kt @@ -0,0 +1,526 @@ +package com.onesignal.location.internal + +import com.onesignal.common.AndroidUtils +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 com.onesignal.location.internal.capture.ILocationCapturer +import com.onesignal.location.internal.common.LocationConstants +import com.onesignal.location.internal.common.LocationUtils +import com.onesignal.location.internal.controller.ILocationController +import com.onesignal.location.internal.permissions.LocationPermissionController +import com.onesignal.mocks.IOMockHelper +import com.onesignal.mocks.IOMockHelper.awaitIO +import com.onesignal.mocks.MockHelper +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.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +private class Mocks { + val locationCapture = mockk(relaxed = true) + val locationController = mockk(relaxed = true) + val permissionController = mockk(relaxed = true) + val mockAppService = MockHelper.applicationService() + + val mockPrefs = + run { + val pref = mockk(relaxed = true) + every { + pref.getBool(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_OS_LOCATION_SHARED, false) + } returns true + pref + } + + val mockContext = + run { + val context = mockk(relaxed = true) + every { mockAppService.appContext } returns context + context + } + + val locationManager = LocationManager( + mockAppService, + locationCapture, + locationController, + permissionController, + mockPrefs, + ) + + fun setAndroidSDKInt(sdkInt: Int) { + every { AndroidUtils.androidSDKInt } returns sdkInt + } + + fun setFineLocationPermission(granted: Boolean) { + every { + AndroidUtils.hasPermission( + LocationConstants.ANDROID_FINE_LOCATION_PERMISSION_STRING, + true, + mockAppService, + ) + } returns granted + } + + fun setCoarseLocationPermission(granted: Boolean) { + every { + AndroidUtils.hasPermission( + LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING, + true, + mockAppService, + ) + } returns granted + } +} + +class LocationManagerTests : FunSpec({ + + listener(IOMockHelper) + + lateinit var mocks: Mocks + + beforeAny { + Logging.logLevel = LogLevel.NONE + mocks = Mocks() // fresh instance for each test + } + + beforeSpec { + // required when testing functions that internally call suspendifyOnMain + Dispatchers.setMain(UnconfinedTestDispatcher()) + mockkObject(LocationUtils) + mockkObject(AndroidUtils) + every { LocationUtils.hasLocationPermission(any()) } returns false + every { AndroidUtils.hasPermission(any(), any(), any()) } returns false + every { AndroidUtils.filterManifestPermissions(any(), any()) } returns emptyList() + } + + afterSpec { + Dispatchers.resetMain() + unmockkObject(LocationUtils) + unmockkObject(AndroidUtils) + } + + test("isShared getter returns value from preferences") { + // Given + val mockPrefs = mocks.mockPrefs + val locationManager = mocks.locationManager + + // When + val result = locationManager.isShared + + // Then + result shouldBe true + verify { + mockPrefs.getBool(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_OS_LOCATION_SHARED, false) + } + } + + test("isShared setter saves value to preferences and triggers permission change") { + // Given + val mockPrefs = mocks.mockPrefs + val mockLocationController = mocks.locationController + coEvery { mockLocationController.start() } returns true + every { LocationUtils.hasLocationPermission(any()) } returns true + val locationManager = mocks.locationManager + + // When + locationManager.isShared = true + + // Then + verify { + mockPrefs.saveBool(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_OS_LOCATION_SHARED, true) + } + locationManager.isShared shouldBe true + } + + test("isShared setter to false does not start location when permission changed") { + // Given + val mockLocationController = mocks.locationController + val locationManager = mocks.locationManager + + // When + locationManager.isShared = false + + // Then + locationManager.isShared shouldBe false + coVerify(exactly = 0) { mockLocationController.start() } + } + + test("start subscribes to location permission controller") { + // Given + every { LocationUtils.hasLocationPermission(mocks.mockAppService.appContext) } returns false + val locationManager = mocks.locationManager + + // When + locationManager.start() + + // Then + verify(exactly = 1) { mocks.permissionController.subscribe(locationManager) } + } + + test("start calls startGetLocation when location permission is granted") { + // Given + every { LocationUtils.hasLocationPermission(mocks.mockAppService.appContext) } returns true + coEvery { mocks.locationController.start() } returns true + + val locationManager = mocks.locationManager + + // When + locationManager.start() + awaitIO() + + // Then + coVerify { mocks.locationController.start() } + } + + test("start does not call startGetLocation when location permission is not granted") { + // Given + val mockLocationController = mockk(relaxed = true) + every { LocationUtils.hasLocationPermission(mocks.mockContext) } returns false + + val locationManager = mocks.locationManager + + // When + locationManager.start() + + // Then + coVerify(exactly = 0) { mockLocationController.start() } + } + + test("onLocationPermissionChanged calls startGetLocation when enabled is true") { + // Given + val mockLocationController = mocks.locationController + coEvery { mockLocationController.start() } returns true + + val locationManager = mocks.locationManager + + // When + locationManager.onLocationPermissionChanged(true) + awaitIO() + + // Then + coVerify { mockLocationController.start() } + } + + test("onLocationPermissionChanged does not call startGetLocation when enabled is false") { + // Given + val locationManager = mocks.locationManager + + // When + locationManager.onLocationPermissionChanged(false) + + // Then + coVerify(exactly = 0) { mocks.locationController.start() } + } + + test("onLocationPermissionChanged does not call startGetLocation when isShared is false") { + // Given + every { + mocks.mockPrefs.getBool(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_OS_LOCATION_SHARED, false) + } returns false + // Create a new LocationManager with isShared = false + val locationManager = LocationManager( + mocks.mockAppService, + mocks.locationCapture, + mocks.locationController, + mocks.permissionController, + mocks.mockPrefs, + ) + + // When + locationManager.onLocationPermissionChanged(true) + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.locationController.start() } + } + + test("requestPermission returns true when fine permission granted on API < 23") { + // Given + mocks.setFineLocationPermission(true) + mocks.setAndroidSDKInt(22) + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + } + + test("requestPermission returns true when coarse permission granted on API < 23") { + // Given + mocks.setFineLocationPermission(false) + mocks.setCoarseLocationPermission(true) + mocks.setAndroidSDKInt(22) + coEvery { mocks.locationController.start() } returns true + + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + coVerify { mocks.locationController.start() } + } + + test("requestPermission returns false when no permissions in manifest on API < 23") { + // Given + val mockApplicationService = mocks.mockAppService + mocks.setFineLocationPermission(false) + mocks.setCoarseLocationPermission(false) + mocks.setAndroidSDKInt(22) + // Ensure filterManifestPermissions returns empty list (no permissions in manifest) + every { + AndroidUtils.filterManifestPermissions(any(), mockApplicationService) + } returns emptyList() + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe false + } + + test("requestPermission returns true when fine permission already granted") { + // Given + mocks.setFineLocationPermission(true) + mocks.setAndroidSDKInt(23) + coEvery { mocks.locationController.start() } returns true + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + coVerify { mocks.locationController.start() } + } + + test("requestPermission prompts for fine permission when not granted and in manifest") { + // Given + val mockApplicationService = mocks.mockAppService + val mockPermissionController = mocks.permissionController + mocks.setFineLocationPermission(false) + mocks.setAndroidSDKInt(23) + every { + AndroidUtils.filterManifestPermissions( + any(), + mockApplicationService, + ) + } returns listOf(LocationConstants.ANDROID_FINE_LOCATION_PERMISSION_STRING) + coEvery { + mockPermissionController.prompt(true, LocationConstants.ANDROID_FINE_LOCATION_PERMISSION_STRING) + } returns true + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + coVerify { + mockPermissionController.prompt(true, LocationConstants.ANDROID_FINE_LOCATION_PERMISSION_STRING) + } + } + + test("requestPermission prompts for coarse permission when fine not in manifest") { + // Given + val mockApplicationService = mocks.mockAppService + val mockPermissionController = mocks.permissionController + mocks.setFineLocationPermission(false) + mocks.setCoarseLocationPermission(true) + mocks.setAndroidSDKInt(23) + every { + AndroidUtils.filterManifestPermissions( + any(), + mockApplicationService, + ) + } returns listOf(LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING) + every { + AndroidUtils.hasPermission( + LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING, + true, + mocks.mockAppService, + ) + } returns false + val locationManager = mocks.locationManager + + // When + locationManager.requestPermission() + + // Then + coVerify { + mockPermissionController.prompt(true, LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING) + } + } + + test("requestPermission returns false when permissions not in manifest") { + // Given + mocks.setFineLocationPermission(false) + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe false + } + + test("requestPermission returns true when coarse permission already granted") { + // Given + mocks.setFineLocationPermission(false) + mocks.setCoarseLocationPermission(true) + + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + } + + test("requestPermission prompts for background permission when fine granted but background not") { + // Given + val mockApplicationService = mocks.mockAppService + val mockPermissionController = mocks.permissionController + mocks.setFineLocationPermission(true) + mocks.setAndroidSDKInt(29) + every { + AndroidUtils.hasPermission( + LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING, + true, + mockApplicationService, + ) + } returns false + every { + AndroidUtils.hasPermission( + LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING, + false, + mockApplicationService, + ) + } returns true + coEvery { + mockPermissionController.prompt(true, LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING) + } returns true + coEvery { mocks.locationController.start() } returns true + + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + coVerify { + mockPermissionController.prompt(true, LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING) + } + } + + test("requestPermission starts location when all permissions granted") { + // Given + val mockApplicationService = mocks.mockAppService + mocks.setFineLocationPermission(true) + every { + AndroidUtils.hasPermission( + LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING, + true, + mockApplicationService, + ) + } returns true + coEvery { mocks.locationController.start() } returns true + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + coVerify { mocks.locationController.start() } + } + + test("requestPermission warns when isShared is false") { + // Given + mocks.setFineLocationPermission(true) + + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + // Warning should be logged (tested indirectly through no exception) + } + + test("requestPermission handles location controller start failure gracefully") { + // Given + mocks.setFineLocationPermission(true) + coEvery { mocks.locationController.start() } returns false + + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + } + + test("requestPermission handles location controller exception gracefully") { + // Given + val mockLocationController = mocks.permissionController + mocks.setFineLocationPermission(true) + coEvery { mockLocationController.start() } throws RuntimeException("Location error") + + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + // Exception should be caught and logged (tested indirectly through no crash) + } + + test("startGetLocation does nothing when isShared is false") { + // Given + val mockLocationController = mocks.locationController + val locationManager = mocks.locationManager + locationManager.isShared = false + + // When - trigger startGetLocation indirectly via onLocationPermissionChanged + locationManager.onLocationPermissionChanged(true) + awaitIO() + + // Then + coVerify(exactly = 0) { mockLocationController.start() } + } + + test("startGetLocation calls location controller start when isShared is true") { + // Given + val mockLocationController = mocks.locationController + coEvery { mockLocationController.start() } returns true + val locationManager = mocks.locationManager + + // When - trigger startGetLocation indirectly via onLocationPermissionChanged + locationManager.onLocationPermissionChanged(true) + awaitIO() + + // Then + coVerify { mockLocationController.start() } + } +}) diff --git a/OneSignalSDK/onesignal/notifications/build.gradle b/OneSignalSDK/onesignal/notifications/build.gradle index 3fb47485b2..f88fa67bf8 100644 --- a/OneSignalSDK/onesignal/notifications/build.gradle +++ b/OneSignalSDK/onesignal/notifications/build.gradle @@ -28,7 +28,7 @@ android { } testOptions { unitTests.all { - maxParallelForks 1 + maxParallelForks Math.max(2, Runtime.runtime.availableProcessors().intdiv(2)) maxHeapSize '2048m' } unitTests { diff --git a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt new file mode 100644 index 0000000000..7296be941e --- /dev/null +++ b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt @@ -0,0 +1,143 @@ +package com.onesignal.mocks + +import com.onesignal.common.threading.OneSignalDispatchers +import com.onesignal.common.threading.suspendifyOnIO +import io.kotest.core.listeners.AfterSpecListener +import io.kotest.core.listeners.BeforeSpecListener +import io.kotest.core.listeners.BeforeTestListener +import io.kotest.core.listeners.TestListener +import io.kotest.core.spec.Spec +import io.kotest.core.test.TestCase +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import java.util.concurrent.atomic.AtomicInteger + +/** + * Test helper that makes OneSignal's async threading behavior deterministic in unit tests. + * Can be helpful to speed up unit tests by replacing all delay(x) or Thread.sleep(x). + * + * In production, `suspendifyOnIO`, `launchOnIO`, and `launchOnDefault` launch work on + * background threads and return immediately. This causes tests to require arbitrary delays + * (e.g., delay(50)) to wait for async work to finish. + * + * This helper avoids that by: + * - Mocking `suspendifyOnIO`, `launchOnIO`, and `launchOnDefault` so their blocks run immediately + * - Completing a `CompletableDeferred` when the async block finishes + * - Providing `awaitIO()` so tests can explicitly wait for all async work without sleeps + * + * Usage example in a Kotest spec: + * class InAppMessagesManagerTests : FunSpec({ + * + * // register to access awaitIO() + * listener(IOMockHelper) + * ... + * + * test("xyz") { + * iamManager.start() // start() calls suspendOnIO or launchOnDefault + * awaitIO() // wait for background work deterministically + * ... + * } + */ +object IOMockHelper : BeforeSpecListener, AfterSpecListener, BeforeTestListener, TestListener { + + private const val THREADUTILS_PATH = "com.onesignal.common.threading.ThreadUtilsKt" + + // How many async blocks (suspendifyOnIO, launchOnIO, launchOnDefault) are currently running + private val pendingIo = AtomicInteger(0) + + // Completed when all in-flight async blocks for the current "wave" are done + @Volatile + private var ioWaiter: CompletableDeferred = CompletableDeferred() + + /** + * Wait for suspendifyOnIO, launchOnIO, and launchOnDefault work to finish. + * Can be called multiple times in a test. + * 1. If multiple async tasks are added before the first task finishes, the waiter will wait until ALL tasks are finished + * 2. If async work is triggered after an awaitIO() has already returned, just call awaitIO() again to wait for the new work. + */ + suspend fun awaitIO(timeoutMs: Long = 5_000) { + // Nothing to wait for in this case + if (pendingIo.get() == 0) return + + withTimeout(timeoutMs) { + ioWaiter.await() + } + } + + override suspend fun beforeSpec(spec: Spec) { + // ThreadUtilsKt = file that contains suspendifyOnIO + mockkStatic(THREADUTILS_PATH) + // OneSignalDispatchers = object that contains launchOnIO and launchOnDefault + mockkObject(OneSignalDispatchers) + + // Helper function to track async work (suspendifyOnIO, launchOnIO, launchOnDefault) + // Note: We use Dispatchers.Unconfined to execute immediately and deterministically + // instead of suspendifyWithCompletion to avoid circular dependency + // (suspendifyWithCompletion calls OneSignalDispatchers.launchOnIO which we're mocking) + fun trackAsyncWork(block: suspend () -> Unit) { + // New async wave: if we are going from 0 -> 1, create a new waiter + val previous = pendingIo.getAndIncrement() + if (previous == 0) { + ioWaiter = CompletableDeferred() + } + + // Execute the block using Unconfined dispatcher to run immediately and deterministically + // This makes tests deterministic and avoids the need for delays + CoroutineScope(SupervisorJob() + Dispatchers.Unconfined).launch { + try { + block() + } catch (e: Exception) { + // Log but don't throw - let the test handle exceptions + } finally { + // When each block finishes, decrement; if all done, complete waiter + if (pendingIo.decrementAndGet() == 0) { + if (!ioWaiter.isCompleted) { + ioWaiter.complete(Unit) + } + } + } + } + } + + every { suspendifyOnIO(any Unit>()) } answers { + val block = firstArg Unit>() + trackAsyncWork(block) + } + + every { OneSignalDispatchers.launchOnIO(any Unit>()) } answers { + val block = firstArg Unit>() + trackAsyncWork(block) + // Return a mock Job (launchOnIO returns a Job) + mockk(relaxed = true) + } + + every { OneSignalDispatchers.launchOnDefault(any Unit>()) } answers { + val block = firstArg Unit>() + trackAsyncWork(block) + // Return a mock Job (launchOnDefault returns a Job) + mockk(relaxed = true) + } + } + + override suspend fun beforeTest(testCase: TestCase) { + // Fresh waiter for each test + pendingIo.set(0) + ioWaiter = CompletableDeferred() + } + + override suspend fun afterSpec(spec: Spec) { + unmockkStatic(THREADUTILS_PATH) + unmockkObject(OneSignalDispatchers) + } +}