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..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,10 +42,16 @@ 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 { + // 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 { + // 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) } } @@ -84,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 3612f81fa0..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 @@ -149,14 +150,21 @@ class PermissionsViewModel : ViewModel() { granted: Boolean, showSettings: Boolean, ) { - val callback = - requestPermissionService.getCallback(permissionRequestType!!) - ?: throw RuntimeException("Missing handler for permissionRequestType: $permissionRequestType") - - if (granted) { - callback.onAccept() - } else { - callback.onReject(showSettings) + 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. + Logging.error("PermissionsViewModel: Cannot resolve callback because permissionRequestType is null. Ending permission flow.") + _shouldFinish.value = true } } 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..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 @@ -1,9 +1,15 @@ 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.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 @@ -12,20 +18,36 @@ 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() + + val testDispatcher = StandardTestDispatcher() beforeTest { + Dispatchers.setMain(testDispatcher) mockkObject(OneSignal) + every { OneSignal.getService() } returns mockRequestService + every { OneSignal.getService() } returns mockPrefService + Logging.logLevel = LogLevel.NONE } afterTest { + Dispatchers.resetMain() unmockkAll() } @@ -35,8 +57,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 +117,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 +179,358 @@ class PermissionsViewModelTests : FunSpec({ viewModel.waiting.first() shouldBe true } } + + 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 + viewModel.onRequestPermissionsResult( + arrayOf(androidPermission), + intArrayOf(PackageManager.PERMISSION_GRANTED), + false, + ) + + // Advance time to complete the delay + 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 finish gracefully without NPE + viewModel.shouldFinish.first() shouldBe true + verify(exactly = 0) { mockRequestService.getCallback(any()) } + } + } + + test("onRequestPermissionsResult with initialized ViewModel calls onAccept when granted") { + 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 with granted permission + viewModel.onRequestPermissionsResult( + arrayOf(androidPermission), + intArrayOf(PackageManager.PERMISSION_GRANTED), + false, + ) + + // Advance time to complete the delay + advanceTimeBy(callbackDelay) + + // 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 + } + } + + 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 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 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, + ) + + // Then - advancing time should trigger the RuntimeException in the coroutine + // The exception will be thrown in viewModelScope and caught by runTest + advanceTimeBy(callbackDelay) + } + } + + exception.message shouldBe "Missing handler for permissionRequestType: $permissionType" + + // Verify the callback lookup was attempted + verify { mockRequestService.getCallback(permissionType) } + } + + 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) + + 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 true // Preference indicates previous denial, so show settings + + viewModel.initialize(activity, permissionType, androidPermission) + + // When - permission is denied again + viewModel.onRequestPermissionsResult( + arrayOf(androidPermission), + intArrayOf(PackageManager.PERMISSION_DENIED), + true, // shouldShowRationaleAfter = true + ) + + // 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 + } + } })