From 78c9882e68bcc578b23f4a0aed13e12261fc3b99 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Wed, 10 Dec 2025 16:43:47 -0500 Subject: [PATCH 1/5] Tests: add test to PermissionViewModelTests to test onRequestPermissionResult --- .../permissions/PermissionsViewModelTests.kt | 98 ++++++++++++++++++- 1 file changed, 93 insertions(+), 5 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt index 6f0eb86ef6..6d3bc6a47b 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt @@ -1,9 +1,12 @@ package com.onesignal.core.internal.permissions import android.app.Activity +import android.content.pm.PackageManager import com.onesignal.OneSignal import com.onesignal.core.internal.permissions.impl.RequestPermissionService import com.onesignal.core.internal.preferences.IPreferencesService +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.mockk.coEvery @@ -12,20 +15,34 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +@OptIn(ExperimentalCoroutinesApi::class) class PermissionsViewModelTests : FunSpec({ val permissionType = "location" val androidPermission = "android.permission.ACCESS_FINE_LOCATION" val mockRequestService = mockk(relaxed = true) val mockPrefService = mockk(relaxed = true) + val callbackDelay = (PermissionsViewModel.DELAY_TIME_CALLBACK_CALL + 50).toLong() beforeTest { + Dispatchers.setMain(StandardTestDispatcher()) mockkObject(OneSignal) + every { OneSignal.getService() } returns mockRequestService + every { OneSignal.getService() } returns mockPrefService + Logging.logLevel = LogLevel.NONE } afterTest { + Dispatchers.resetMain() unmockkAll() } @@ -35,8 +52,6 @@ class PermissionsViewModelTests : FunSpec({ // Mock the services that will be accessed via lazy initialization coEvery { OneSignal.initWithContext(any()) } returns true - every { OneSignal.getService() } returns mockRequestService - every { OneSignal.getService() } returns mockPrefService runBlocking { val result = viewModel.initialize(activity, permissionType, androidPermission) @@ -97,9 +112,6 @@ class PermissionsViewModelTests : FunSpec({ test("recordRationaleState sets the rationale state") { val viewModel = PermissionsViewModel() - // Mock the service - every { OneSignal.getService() } returns mockRequestService - viewModel.recordRationaleState(true) verify { mockRequestService.shouldShowRequestPermissionRationaleBeforeRequest = true } @@ -162,4 +174,80 @@ class PermissionsViewModelTests : FunSpec({ viewModel.waiting.first() shouldBe true } } + + test("onRequestPermissionsResult with uninitialized ViewModel finishes gracefully") { + runTest { + // Given - ViewModel is not initialized (permissionRequestType is null) + val viewModel = PermissionsViewModel() + + // When - onRequestPermissionsResult is called before initialize() completes (race condition) + viewModel.onRequestPermissionsResult( + arrayOf(androidPermission), + intArrayOf(PackageManager.PERMISSION_GRANTED), + false, + ) + + // Advance time to complete the delay + advanceTimeBy(callbackDelay) // DELAY_TIME_CALLBACK_CALL (500ms) + buffer + + // Then - should not throw exception + } + } + + test("onRequestPermissionsResult with initialized ViewModel calls callback") { + runTest { + // Given - ViewModel is properly initialized + val viewModel = PermissionsViewModel() + val activity = mockk(relaxed = true) + val mockCallback = mockk(relaxed = true) + + coEvery { OneSignal.initWithContext(any()) } returns true + every { mockRequestService.getCallback(permissionType) } returns mockCallback + + viewModel.initialize(activity, permissionType, androidPermission) + + // When - onRequestPermissionsResult is called + viewModel.onRequestPermissionsResult( + arrayOf(androidPermission), + intArrayOf(PackageManager.PERMISSION_GRANTED), + false, + ) + + // Advance time to complete the delay + advanceTimeBy(callbackDelay) + + // Then - callback should be called + verify { mockCallback.onAccept() } + viewModel.shouldFinish.first() shouldBe true + } + } + + test("onRequestPermissionsResult with initialized ViewModel calls onReject when denied") { + runTest { + // Given - ViewModel is properly initialized + val viewModel = PermissionsViewModel() + val activity = mockk(relaxed = true) + val mockCallback = mockk(relaxed = true) + + coEvery { OneSignal.initWithContext(any()) } returns true + every { mockRequestService.getCallback(permissionType) } returns mockCallback + every { mockRequestService.fallbackToSettings } returns false + + viewModel.initialize(activity, permissionType, androidPermission) + + // When - onRequestPermissionsResult is called with denied permission + viewModel.onRequestPermissionsResult( + arrayOf(androidPermission), + intArrayOf(PackageManager.PERMISSION_DENIED), + false, + ) + + // Advance time to complete the delay + advanceTimeBy(callbackDelay) + + // Then - callback.onReject should be called + verify { mockCallback.onReject(false) } + viewModel.shouldFinish.first() shouldBe true + } + } }) From b1374ac333965fbccf9098e578694482f3e11277 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Thu, 11 Dec 2025 00:12:20 -0500 Subject: [PATCH 2/5] fix: NPE from PermissionViewModel --- .../onesignal/core/activities/PermissionsActivity.kt | 11 +++++------ .../core/internal/permissions/PermissionsViewModel.kt | 7 +++++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt index 6f1d3bdfca..8503f76ea8 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt @@ -42,12 +42,11 @@ class PermissionsActivity : ComponentActivity() { } } - // Only handle bundle params on first creation, not on config changes - // ViewModel retains state across config changes, so permission state survives rotation - if (savedInstanceState == null) { - lifecycleScope.launch { - handleBundleParams(intent.extras) - } + // Always handle bundle params even on config changes + // If app process is killed before a permission result is captured, the new view model may not + // be initialized if we skip handleBundleParams + lifecycleScope.launch { + handleBundleParams(intent.extras) } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt index 3612f81fa0..acb005a25c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt @@ -149,6 +149,13 @@ class PermissionsViewModel : ViewModel() { granted: Boolean, showSettings: Boolean, ) { + if (permissionRequestType == null) { + // There is a small chance ViewModel was never fully initialized (e.g. process death or OneSignal init hanging while prompting). + // We can't safely resolve a callback in this state, so just finish the flow. + _shouldFinish.value = true + return + } + val callback = requestPermissionService.getCallback(permissionRequestType!!) ?: throw RuntimeException("Missing handler for permissionRequestType: $permissionRequestType") From ce6b6a902c05f2e6d1e98c02b6c175553f64ea6d Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 11 Dec 2025 11:11:47 -0500 Subject: [PATCH 3/5] refactored code to avoid crashes --- .../core/activities/PermissionsActivity.kt | 38 ++- .../permissions/PermissionsViewModel.kt | 23 +- .../permissions/PermissionsViewModelTests.kt | 290 +++++++++++++++++- 3 files changed, 317 insertions(+), 34 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt index 8503f76ea8..7d187a1220 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt @@ -42,11 +42,18 @@ class PermissionsActivity : ComponentActivity() { } } - // Always handle bundle params even on config changes - // If app process is killed before a permission result is captured, the new view model may not - // be initialized if we skip handleBundleParams + // Handle bundle params if ViewModel is not already initialized + // This covers: + // 1. Process death: savedInstanceState == null, ViewModel is recreated, needs initialization + // 2. Config change: savedInstanceState != null, ViewModel survives, but we check if already initialized + // 3. Race condition: onRequestPermissionsResult might arrive before initialization completes + // (handled by null check in executeCallback) lifecycleScope.launch { - handleBundleParams(intent.extras) + // Only initialize if ViewModel is not already initialized + // On config changes, ViewModel survives with its state, so we skip re-initialization + if (viewModel.permissionRequestType == null) { + handleBundleParams(intent.extras) + } } } @@ -83,19 +90,22 @@ class PermissionsActivity : ComponentActivity() { return } - reregisterCallbackHandlers(extras) + extras?.let { bundle -> + reregisterCallbackHandlers(bundle) + val permissionType = bundle.getString(INTENT_EXTRA_PERMISSION_TYPE) + val androidPermissionString = bundle.getString(INTENT_EXTRA_ANDROID_PERMISSION_STRING) - val permissionType = extras!!.getString(INTENT_EXTRA_PERMISSION_TYPE) - val androidPermissionString = extras.getString(INTENT_EXTRA_ANDROID_PERMISSION_STRING) + // Initialize OneSignal and ViewModel (handles initialization in one place) + if (!viewModel.initialize(this, permissionType, androidPermissionString)) { + finishActivity() + return@let + } - // Initialize OneSignal and ViewModel (handles initialization in one place) - if (!viewModel.initialize(this, permissionType, androidPermissionString)) { - finishActivity() - return + // Request permission - this is Activity-layer logic + androidPermissionString?.let { permission -> + requestPermission(permission) + } } - - // Request permission - this is Activity-layer logic - requestPermission(androidPermissionString!!) } // Required if the app was killed while this prompt was showing diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt index acb005a25c..ba8f54d5cf 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt @@ -149,21 +149,20 @@ class PermissionsViewModel : ViewModel() { granted: Boolean, showSettings: Boolean, ) { - if (permissionRequestType == null) { + permissionRequestType?.let { type -> + val callback = + requestPermissionService.getCallback(type) + ?: throw RuntimeException("Missing handler for permissionRequestType: $type") + + if (granted) { + callback.onAccept() + } else { + callback.onReject(showSettings) + } + } ?: run { // There is a small chance ViewModel was never fully initialized (e.g. process death or OneSignal init hanging while prompting). // We can't safely resolve a callback in this state, so just finish the flow. _shouldFinish.value = true - return - } - - val callback = - requestPermissionService.getCallback(permissionRequestType!!) - ?: throw RuntimeException("Missing handler for permissionRequestType: $permissionRequestType") - - if (granted) { - callback.onAccept() - } else { - callback.onReject(showSettings) } } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt index 6d3bc6a47b..3cf51c4c53 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt @@ -5,6 +5,8 @@ import android.content.pm.PackageManager import com.onesignal.OneSignal import com.onesignal.core.internal.permissions.impl.RequestPermissionService import com.onesignal.core.internal.preferences.IPreferencesService +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import io.kotest.core.spec.style.FunSpec @@ -175,12 +177,40 @@ class PermissionsViewModelTests : FunSpec({ } } - test("onRequestPermissionsResult with uninitialized ViewModel finishes gracefully") { + test("initialize returns false when permissionType is null") { + val viewModel = PermissionsViewModel() + val activity = mockk(relaxed = true) + + coEvery { OneSignal.initWithContext(any()) } returns true + + runBlocking { + val result = viewModel.initialize(activity, null, androidPermission) + result shouldBe false + viewModel.shouldFinish.first() shouldBe true + } + } + + test("initialize returns false when androidPermission is null") { + val viewModel = PermissionsViewModel() + val activity = mockk(relaxed = true) + + coEvery { OneSignal.initWithContext(any()) } returns true + + runBlocking { + val result = viewModel.initialize(activity, permissionType, null) + result shouldBe false + viewModel.shouldFinish.first() shouldBe true + } + } + + test("onRequestPermissionsResult with uninitialized ViewModel finishes gracefully without NPE") { runTest { // Given - ViewModel is not initialized (permissionRequestType is null) + // This simulates process death or race condition where onRequestPermissionsResult + // is called before initialize() completes val viewModel = PermissionsViewModel() - // When - onRequestPermissionsResult is called before initialize() completes (race condition) + // When - onRequestPermissionsResult is called before initialize() completes viewModel.onRequestPermissionsResult( arrayOf(androidPermission), intArrayOf(PackageManager.PERMISSION_GRANTED), @@ -188,13 +218,37 @@ class PermissionsViewModelTests : FunSpec({ ) // Advance time to complete the delay - advanceTimeBy(callbackDelay) // DELAY_TIME_CALLBACK_CALL (500ms) + buffer + advanceTimeBy(callbackDelay) + + // Then - should not throw NPE and should finish gracefully + viewModel.shouldFinish.first() shouldBe true + // Verify no callback was attempted (since permissionRequestType is null) + verify(exactly = 0) { mockRequestService.getCallback(any()) } + } + } + + test("onRequestPermissionsResult with uninitialized ViewModel handles denied permission gracefully") { + runTest { + // Given - ViewModel is not initialized + val viewModel = PermissionsViewModel() + + // When - onRequestPermissionsResult is called with denied permission + viewModel.onRequestPermissionsResult( + arrayOf(androidPermission), + intArrayOf(PackageManager.PERMISSION_DENIED), + false, + ) + + // Advance time to complete the delay + advanceTimeBy(callbackDelay) - // Then - should not throw exception + // Then - should finish gracefully without NPE + viewModel.shouldFinish.first() shouldBe true + verify(exactly = 0) { mockRequestService.getCallback(any()) } } } - test("onRequestPermissionsResult with initialized ViewModel calls callback") { + test("onRequestPermissionsResult with initialized ViewModel calls onAccept when granted") { runTest { // Given - ViewModel is properly initialized val viewModel = PermissionsViewModel() @@ -206,7 +260,7 @@ class PermissionsViewModelTests : FunSpec({ viewModel.initialize(activity, permissionType, androidPermission) - // When - onRequestPermissionsResult is called + // When - onRequestPermissionsResult is called with granted permission viewModel.onRequestPermissionsResult( arrayOf(androidPermission), intArrayOf(PackageManager.PERMISSION_GRANTED), @@ -216,8 +270,15 @@ class PermissionsViewModelTests : FunSpec({ // Advance time to complete the delay advanceTimeBy(callbackDelay) - // Then - callback should be called + // Then - callback.onAccept should be called and preference should be saved verify { mockCallback.onAccept() } + verify { + mockPrefService.saveBool( + PreferenceStores.ONESIGNAL, + "${PreferenceOneSignalKeys.PREFS_OS_USER_RESOLVED_PERMISSION_PREFIX}$androidPermission", + true, + ) + } viewModel.shouldFinish.first() shouldBe true } } @@ -245,9 +306,222 @@ class PermissionsViewModelTests : FunSpec({ // Advance time to complete the delay advanceTimeBy(callbackDelay) - // Then - callback.onReject should be called + // Then - callback.onReject should be called with showSettings = false + verify { mockCallback.onReject(false) } + viewModel.shouldFinish.first() shouldBe true + } + } + + test("onRequestPermissionsResult with empty permissions array handles gracefully") { + runTest { + // Given - ViewModel is initialized + val viewModel = PermissionsViewModel() + val activity = mockk(relaxed = true) + val mockCallback = mockk(relaxed = true) + + coEvery { OneSignal.initWithContext(any()) } returns true + every { mockRequestService.getCallback(permissionType) } returns mockCallback + + viewModel.initialize(activity, permissionType, androidPermission) + + // When - onRequestPermissionsResult is called with empty permissions + viewModel.onRequestPermissionsResult( + arrayOf(), + intArrayOf(), + false, + ) + + // Advance time to complete the delay + advanceTimeBy(callbackDelay) + + // Then - callback.onReject should be called (treated as denied) + verify { mockCallback.onReject(false) } + viewModel.shouldFinish.first() shouldBe true + } + } + + test("onRequestPermissionsResult with empty grantResults treats as denied") { + runTest { + // Given - ViewModel is initialized + val viewModel = PermissionsViewModel() + val activity = mockk(relaxed = true) + val mockCallback = mockk(relaxed = true) + + coEvery { OneSignal.initWithContext(any()) } returns true + every { mockRequestService.getCallback(permissionType) } returns mockCallback + every { mockRequestService.fallbackToSettings } returns false + + viewModel.initialize(activity, permissionType, androidPermission) + + // When - onRequestPermissionsResult is called with empty grantResults + viewModel.onRequestPermissionsResult( + arrayOf(androidPermission), + intArrayOf(), + false, + ) + + // Advance time to complete the delay + advanceTimeBy(callbackDelay) + + // Then - callback.onReject should be called (empty grantResults = denied) + verify { mockCallback.onReject(false) } + viewModel.shouldFinish.first() shouldBe true + } + } + + test("onRequestPermissionsResult throws RuntimeException when callback is missing") { + runTest { + // Given - ViewModel is initialized but callback is not registered + val viewModel = PermissionsViewModel() + val activity = mockk(relaxed = true) + + coEvery { OneSignal.initWithContext(any()) } returns true + every { mockRequestService.getCallback(permissionType) } returns null + + viewModel.initialize(activity, permissionType, androidPermission) + + // When/Then - onRequestPermissionsResult should throw RuntimeException + viewModel.onRequestPermissionsResult( + arrayOf(androidPermission), + intArrayOf(PackageManager.PERMISSION_GRANTED), + false, + ) + + // Advance time to complete the delay + advanceTimeBy(callbackDelay) + + // The exception should be thrown in the coroutine scope + // We can't easily catch it in viewModelScope, but we verify the callback lookup was attempted + verify { mockRequestService.getCallback(permissionType) } + } + } + + test("onRequestPermissionsResult shows settings when fallbackToSettings is true and not permanently denied") { + runTest { + // Given - ViewModel is initialized with fallback to settings enabled + val viewModel = PermissionsViewModel() + val activity = mockk(relaxed = true) + val mockCallback = mockk(relaxed = true) + + coEvery { OneSignal.initWithContext(any()) } returns true + every { mockRequestService.getCallback(permissionType) } returns mockCallback + every { mockRequestService.fallbackToSettings } returns true + every { + mockPrefService.getBool( + PreferenceStores.ONESIGNAL, + "${PreferenceOneSignalKeys.PREFS_OS_USER_RESOLVED_PERMISSION_PREFIX}$androidPermission", + false, + ) + } returns false + + viewModel.initialize(activity, permissionType, androidPermission) + + // When - permission is denied (first time, not permanently) + viewModel.onRequestPermissionsResult( + arrayOf(androidPermission), + intArrayOf(PackageManager.PERMISSION_DENIED), + true, // shouldShowRationaleAfter = true (first denial) + ) + + // Advance time to complete the delay + advanceTimeBy(callbackDelay) + + // Then - callback.onReject should be called with showSettings = true + verify { mockCallback.onReject(true) } + viewModel.shouldFinish.first() shouldBe true + } + } + + test("onRequestPermissionsResult does not show settings when permanently denied") { + runTest { + // Given - ViewModel is initialized, rationale changed from true to false (permanent denial) + val viewModel = PermissionsViewModel() + val activity = mockk(relaxed = true) + val mockCallback = mockk(relaxed = true) + + coEvery { OneSignal.initWithContext(any()) } returns true + every { mockRequestService.getCallback(permissionType) } returns mockCallback + every { mockRequestService.fallbackToSettings } returns true + every { mockRequestService.shouldShowRequestPermissionRationaleBeforeRequest } returns true + + viewModel.initialize(activity, permissionType, androidPermission) + viewModel.recordRationaleState(true) // Set before request + + // When - permission is denied and rationale changed from true to false (permanent denial) + viewModel.onRequestPermissionsResult( + arrayOf(androidPermission), + intArrayOf(PackageManager.PERMISSION_DENIED), + false, // shouldShowRationaleAfter = false (permanent denial) + ) + + // Advance time to complete the delay + advanceTimeBy(callbackDelay) + + // Then - callback.onReject should be called with showSettings = false + // and preference should be saved to remember permanent denial verify { mockCallback.onReject(false) } + verify { + mockPrefService.saveBool( + PreferenceStores.ONESIGNAL, + "${PreferenceOneSignalKeys.PREFS_OS_USER_RESOLVED_PERMISSION_PREFIX}$androidPermission", + true, + ) + } viewModel.shouldFinish.first() shouldBe true } } + + test("onRequestPermissionsResult does not show settings when fallbackToSettings is false") { + runTest { + // Given - ViewModel is initialized with fallback to settings disabled + val viewModel = PermissionsViewModel() + val activity = mockk(relaxed = true) + val mockCallback = mockk(relaxed = true) + + coEvery { OneSignal.initWithContext(any()) } returns true + every { mockRequestService.getCallback(permissionType) } returns mockCallback + every { mockRequestService.fallbackToSettings } returns false + + viewModel.initialize(activity, permissionType, androidPermission) + + // When - permission is denied + viewModel.onRequestPermissionsResult( + arrayOf(androidPermission), + intArrayOf(PackageManager.PERMISSION_DENIED), + true, + ) + + // Advance time to complete the delay + advanceTimeBy(callbackDelay) + + // Then - callback.onReject should be called with showSettings = false + verify { mockCallback.onReject(false) } + viewModel.shouldFinish.first() shouldBe true + } + } + + test("onRequestPermissionsResult resets waiting state") { + runTest { + // Given - ViewModel is initialized and waiting + val viewModel = PermissionsViewModel() + val activity = mockk(relaxed = true) + val mockCallback = mockk(relaxed = true) + + coEvery { OneSignal.initWithContext(any()) } returns true + every { mockRequestService.getCallback(permissionType) } returns mockCallback + + viewModel.initialize(activity, permissionType, androidPermission) + viewModel.shouldRequestPermission() // Set waiting to true + + // When - onRequestPermissionsResult is called + viewModel.onRequestPermissionsResult( + arrayOf(androidPermission), + intArrayOf(PackageManager.PERMISSION_GRANTED), + false, + ) + + // Then - waiting should be reset to false immediately (before delay) + viewModel.waiting.first() shouldBe false + } + } }) From ad2aeb2cad50b42475cea1ed054fadb17d1a4885 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Thu, 11 Dec 2025 11:34:48 -0500 Subject: [PATCH 4/5] Add logging --- .../onesignal/core/internal/permissions/PermissionsViewModel.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt index ba8f54d5cf..c4bf12f979 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/permissions/PermissionsViewModel.kt @@ -9,6 +9,7 @@ import com.onesignal.core.internal.permissions.impl.RequestPermissionService import com.onesignal.core.internal.preferences.IPreferencesService import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.debug.internal.logging.Logging import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -162,6 +163,7 @@ class PermissionsViewModel : ViewModel() { } ?: run { // There is a small chance ViewModel was never fully initialized (e.g. process death or OneSignal init hanging while prompting). // We can't safely resolve a callback in this state, so just finish the flow. + Logging.error("PermissionsViewModel: Cannot resolve callback because permissionRequestType is null. Ending permission flow.") _shouldFinish.value = true } } From 45eaa08c5d570efff692e726e5087e7373d87445 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 11 Dec 2025 11:42:24 -0500 Subject: [PATCH 5/5] fix test --- .../permissions/PermissionsViewModelTests.kt | 61 +++++++++++-------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt index 3cf51c4c53..81199d6442 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/permissions/PermissionsViewModelTests.kt @@ -9,6 +9,7 @@ import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.mockk.coEvery @@ -35,8 +36,10 @@ class PermissionsViewModelTests : FunSpec({ val mockPrefService = mockk(relaxed = true) val callbackDelay = (PermissionsViewModel.DELAY_TIME_CALLBACK_CALL + 50).toLong() + val testDispatcher = StandardTestDispatcher() + beforeTest { - Dispatchers.setMain(StandardTestDispatcher()) + Dispatchers.setMain(testDispatcher) mockkObject(OneSignal) every { OneSignal.getService() } returns mockRequestService every { OneSignal.getService() } returns mockPrefService @@ -370,35 +373,41 @@ class PermissionsViewModelTests : FunSpec({ } test("onRequestPermissionsResult throws RuntimeException when callback is missing") { - runTest { - // Given - ViewModel is initialized but callback is not registered - val viewModel = PermissionsViewModel() - val activity = mockk(relaxed = true) - - coEvery { OneSignal.initWithContext(any()) } returns true - every { mockRequestService.getCallback(permissionType) } returns null - - viewModel.initialize(activity, permissionType, androidPermission) + // runTest will catch uncaught exceptions from coroutines + val exception = shouldThrow { + runTest(testDispatcher) { + // Given - ViewModel is initialized but callback is not registered + val viewModel = PermissionsViewModel() + val activity = mockk(relaxed = true) + + coEvery { OneSignal.initWithContext(any()) } returns true + every { mockRequestService.getCallback(permissionType) } returns null + + viewModel.initialize(activity, permissionType, androidPermission) + + // When - onRequestPermissionsResult is called + viewModel.onRequestPermissionsResult( + arrayOf(androidPermission), + intArrayOf(PackageManager.PERMISSION_GRANTED), + false, + ) - // When/Then - onRequestPermissionsResult should throw RuntimeException - viewModel.onRequestPermissionsResult( - arrayOf(androidPermission), - intArrayOf(PackageManager.PERMISSION_GRANTED), - false, - ) + // Then - advancing time should trigger the RuntimeException in the coroutine + // The exception will be thrown in viewModelScope and caught by runTest + advanceTimeBy(callbackDelay) + } + } - // Advance time to complete the delay - advanceTimeBy(callbackDelay) + exception.message shouldBe "Missing handler for permissionRequestType: $permissionType" - // The exception should be thrown in the coroutine scope - // We can't easily catch it in viewModelScope, but we verify the callback lookup was attempted - verify { mockRequestService.getCallback(permissionType) } - } + // Verify the callback lookup was attempted + verify { mockRequestService.getCallback(permissionType) } } - test("onRequestPermissionsResult shows settings when fallbackToSettings is true and not permanently denied") { + test("onRequestPermissionsResult shows settings when fallbackToSettings is true and preference indicates previous denial") { runTest { // Given - ViewModel is initialized with fallback to settings enabled + // and preference indicates user previously denied (so we should show settings) val viewModel = PermissionsViewModel() val activity = mockk(relaxed = true) val mockCallback = mockk(relaxed = true) @@ -412,15 +421,15 @@ class PermissionsViewModelTests : FunSpec({ "${PreferenceOneSignalKeys.PREFS_OS_USER_RESOLVED_PERMISSION_PREFIX}$androidPermission", false, ) - } returns false + } returns true // Preference indicates previous denial, so show settings viewModel.initialize(activity, permissionType, androidPermission) - // When - permission is denied (first time, not permanently) + // When - permission is denied again viewModel.onRequestPermissionsResult( arrayOf(androidPermission), intArrayOf(PackageManager.PERMISSION_DENIED), - true, // shouldShowRationaleAfter = true (first denial) + true, // shouldShowRationaleAfter = true ) // Advance time to complete the delay