diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt index d43af8a32d..74d31c4669 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt @@ -193,11 +193,30 @@ class ConfigModel : Model() { * The minimum number of milliseconds required to pass to allow the fetching of IAM to occur. */ var fetchIAMMinInterval: Long - get() = getLongProperty(::fetchIAMMinInterval.name) { 30000 } + get() = getLongProperty(::fetchIAMMinInterval.name) { 30_000 } set(value) { setLongProperty(::fetchIAMMinInterval.name, value) } + /** + * The number of milliseconds between fetching the current notification permission value when the app is in focus + */ + var foregroundFetchNotificationPermissionInterval: Long + get() = getLongProperty(::foregroundFetchNotificationPermissionInterval.name) { 1_000 } + set(value) { + setLongProperty(::foregroundFetchNotificationPermissionInterval.name, value) + } + + /** + * The number of milliseconds between fetching the current notification permission value when the app is out of focus + * We want this value to be very large to effectively stop polling in the background + */ + var backgroundFetchNotificationPermissionInterval: Long + get() = getLongProperty(::backgroundFetchNotificationPermissionInterval.name) { 86_400_000 } + set(value) { + setLongProperty(::backgroundFetchNotificationPermissionInterval.name, value) + } + /** * The google project number for GMS devices. */ diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/permissions/impl/NotificationPermissionController.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/permissions/impl/NotificationPermissionController.kt index b60f7700ac..38375986c7 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/permissions/impl/NotificationPermissionController.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/permissions/impl/NotificationPermissionController.kt @@ -31,9 +31,11 @@ import android.os.Build import androidx.annotation.ChecksSdkIntAtLeast import com.onesignal.common.AndroidUtils import com.onesignal.common.events.EventProducer +import com.onesignal.common.threading.Waiter import com.onesignal.common.threading.WaiterWithValue import com.onesignal.core.internal.application.ApplicationLifecycleHandlerBase import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.permissions.AlertDialogPrepromptForAndroidSettings import com.onesignal.core.internal.permissions.IRequestPermissionService import com.onesignal.core.internal.preferences.IPreferencesService @@ -43,6 +45,10 @@ import com.onesignal.notifications.R import com.onesignal.notifications.internal.common.NotificationHelper import com.onesignal.notifications.internal.permissions.INotificationPermissionChangedHandler import com.onesignal.notifications.internal.permissions.INotificationPermissionController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.yield internal class NotificationPermissionController( @@ -50,10 +56,15 @@ internal class NotificationPermissionController( private val _requestPermission: IRequestPermissionService, private val _applicationService: IApplicationService, private val _preferenceService: IPreferencesService, + private val _configModelStore: ConfigModelStore, ) : IRequestPermissionService.PermissionCallback, INotificationPermissionController { private val waiter = WaiterWithValue() + private val pollingWaiter = Waiter() + private var pollingWaitInterval: Long private val events = EventProducer() + private var enabled: Boolean + private val coroutineScope = CoroutineScope(newSingleThreadContext(name = "NotificationPermissionController")) override val canRequestPermission: Boolean get() = @@ -64,7 +75,44 @@ internal class NotificationPermissionController( )!! init { + this.enabled = notificationsEnabled() _requestPermission.registerAsCallback(PERMISSION_TYPE, this) + pollingWaitInterval = _configModelStore.model.backgroundFetchNotificationPermissionInterval + registerPollingLifecycleListener() + coroutineScope.launch { + pollForPermission() + } + } + + private fun registerPollingLifecycleListener() { + _applicationService.addApplicationLifecycleHandler( + object : ApplicationLifecycleHandlerBase() { + override fun onFocus() { + super.onFocus() + pollingWaitInterval = _configModelStore.model.foregroundFetchNotificationPermissionInterval + pollingWaiter.wake() + } + + override fun onUnfocused() { + super.onUnfocused() + // Changing the polling interval to 1 day to effectively pause polling + pollingWaitInterval = _configModelStore.model.backgroundFetchNotificationPermissionInterval + } + }, + ) + } + + private suspend fun pollForPermission() { + while (true) { + val enabled = this.notificationsEnabled() + if (this.enabled != enabled) { // If the permission has changed without prompting through OneSignal + this.enabled = enabled + events.fire { it.onNotificationPermissionChanged(enabled) } + } + withTimeoutOrNull(pollingWaitInterval) { + pollingWaiter.waitForWake() + } + } } @ChecksSdkIntAtLeast(api = 33) @@ -72,6 +120,12 @@ internal class NotificationPermissionController( Build.VERSION.SDK_INT > 32 && AndroidUtils.getTargetSdkVersion(_application.appContext) > 32 + private fun permissionPromptCompleted(enabled: Boolean) { + this.enabled = enabled + waiter.wake(enabled) + events.fire { it.onNotificationPermissionChanged(enabled) } + } + /** * Prompt the user for notification permission. Note it is possible the application * will be killed while the permission prompt is being displayed to the user. When the @@ -119,8 +173,7 @@ internal class NotificationPermissionController( get() = events.hasSubscribers override fun onAccept() { - waiter.wake(true) - events.fire { it.onNotificationPermissionChanged(true) } + permissionPromptCompleted(true) } override fun onReject(fallbackToSettings: Boolean) { @@ -132,8 +185,7 @@ internal class NotificationPermissionController( } if (!fallbackShown) { - waiter.wake(false) - events.fire { it.onNotificationPermissionChanged(false) } + permissionPromptCompleted(false) } } @@ -154,8 +206,7 @@ internal class NotificationPermissionController( super.onFocus() _applicationService.removeApplicationLifecycleHandler(this) val hasPermission = AndroidUtils.hasPermission(ANDROID_PERMISSION_STRING, true, _applicationService) - waiter.wake(hasPermission) - events.fire { it.onNotificationPermissionChanged(hasPermission) } + permissionPromptCompleted(hasPermission) } }, ) @@ -163,8 +214,7 @@ internal class NotificationPermissionController( } override fun onDecline() { - waiter.wake(false) - events.fire { it.onNotificationPermissionChanged(false) } + permissionPromptCompleted(false) } }, ) diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/permission/NotificationPermissionControllerTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/permission/NotificationPermissionControllerTests.kt new file mode 100644 index 0000000000..be1e290c69 --- /dev/null +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/permission/NotificationPermissionControllerTests.kt @@ -0,0 +1,167 @@ +package com.onesignal.notifications.internal.permission + +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.core.internal.application.IApplicationLifecycleHandler +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.permissions.IRequestPermissionService +import com.onesignal.core.internal.preferences.IPreferencesService +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.mocks.MockHelper +import com.onesignal.notifications.internal.permissions.INotificationPermissionChangedHandler +import com.onesignal.notifications.internal.permissions.impl.NotificationPermissionController +import com.onesignal.notifications.shadows.ShadowRoboNotificationManager +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.delay +import org.robolectric.annotation.Config + +@Config( + packageName = "com.onesignal.example", + shadows = [ShadowRoboNotificationManager::class], + sdk = [33], +) +@RobolectricTest +class NotificationPermissionControllerTests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + ShadowRoboNotificationManager.reset() + } + + beforeEach { + ShadowRoboNotificationManager.reset() + } + + test("NotificationPermissionController permission polling fires permission changed event") { + // Given + val mockRequestPermissionService = mockk() + every { mockRequestPermissionService.registerAsCallback(any(), any()) } just runs + val mockPreferenceService = mockk() + val focusHandlerList = mutableListOf() + val mockAppService = mockk() + every { mockAppService.addApplicationLifecycleHandler(any()) } answers { + focusHandlerList.add(firstArg()) + } + every { mockAppService.appContext } returns ApplicationProvider.getApplicationContext() + var handlerFired = false + val notificationPermissionController = NotificationPermissionController(mockAppService, mockRequestPermissionService, mockAppService, mockPreferenceService, MockHelper.configModelStore()) + + notificationPermissionController.subscribe( + object : INotificationPermissionChangedHandler { + override fun onNotificationPermissionChanged(enabled: Boolean) { + handlerFired = true + } + }, + ) + // call onFocus to set the proper polling interval. + // This happens when registering the lifecycle handler + for (focusHandler in focusHandlerList) { + focusHandler.onFocus() + } + + // When + // permission changes + ShadowRoboNotificationManager.setShadowNotificationsEnabled(false) + delay(100) + + // Then + // permissionChanged Event should fire + handlerFired shouldBe true + } + + test("NotificationPermissionController permission polling pauses when app loses") { + // Given + val mockRequestPermissionService = mockk() + every { mockRequestPermissionService.registerAsCallback(any(), any()) } just runs + val mockPreferenceService = mockk() + val handlerList = mutableListOf() + val mockAppService = mockk() + every { mockAppService.addApplicationLifecycleHandler(any()) } answers { + handlerList.add(firstArg()) + } + every { mockAppService.appContext } returns ApplicationProvider.getApplicationContext() + + var handlerFired = false + val notificationPermissionController = NotificationPermissionController(mockAppService, mockRequestPermissionService, mockAppService, mockPreferenceService, MockHelper.configModelStore()) + + notificationPermissionController.subscribe( + object : INotificationPermissionChangedHandler { + override fun onNotificationPermissionChanged(enabled: Boolean) { + handlerFired = true + } + }, + ) + // call onFocus to set the proper polling interval. + // This happens when registering the lifecycle handler + for (focusHandler in handlerList) { + focusHandler.onFocus() + } + + // When + // the app has loses focus + for (handler in handlerList) { + handler.onUnfocused() + } + delay(100) + // the permission changes + ShadowRoboNotificationManager.setShadowNotificationsEnabled(false) + delay(100) + + // Then + // permissionChanged Event should not fire + handlerFired shouldBe false + } + + test("NotificationPermissionController permission polling resumes when app gains focus") { + // Given + val mockRequestPermissionService = mockk() + every { mockRequestPermissionService.registerAsCallback(any(), any()) } just runs + val mockPreferenceService = mockk() + val handlerList = mutableListOf() + val mockAppService = mockk() + every { mockAppService.addApplicationLifecycleHandler(any()) } answers { + handlerList.add(firstArg()) + } + every { mockAppService.appContext } returns ApplicationProvider.getApplicationContext() + + var handlerFired = false + val notificationPermissionController = NotificationPermissionController(mockAppService, mockRequestPermissionService, mockAppService, mockPreferenceService, MockHelper.configModelStore()) + + notificationPermissionController.subscribe( + object : INotificationPermissionChangedHandler { + override fun onNotificationPermissionChanged(enabled: Boolean) { + handlerFired = true + } + }, + ) + // call onFocus to set the proper polling interval. + // This happens when registering the lifecycle handler + for (focusHandler in handlerList) { + focusHandler.onFocus() + } + + // When + // the app loses focus + for (handler in handlerList) { + handler.onUnfocused() + } + delay(100) + // the permission changes + ShadowRoboNotificationManager.setShadowNotificationsEnabled(false) + delay(100) + // the app regains focus + for (handler in handlerList) { + handler.onFocus() + } + delay(5) + + // Then + // permissionChanged Event should fire + handlerFired shouldBe true + } +}) diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/shadows/ShadowRoboNotificationManager.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/shadows/ShadowRoboNotificationManager.kt index bb6cec0b78..8b3e6fc66a 100644 --- a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/shadows/ShadowRoboNotificationManager.kt +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/shadows/ShadowRoboNotificationManager.kt @@ -79,6 +79,14 @@ class ShadowRoboNotificationManager : ShadowNotificationManager() { super.notify(tag, id, notification) } + override fun setNotificationsEnabled(areNotificationsEnabled: Boolean) { + notificationsEnabled = areNotificationsEnabled + } + + override fun areNotificationsEnabled(): Boolean { + return notificationsEnabled + } + fun createNotificationChannel(channel: NotificationChannel?) { lastChannel = channel super.createNotificationChannel(channel as Any?) @@ -97,12 +105,14 @@ class ShadowRoboNotificationManager : ShadowNotificationManager() { var lastNotifId = 0 val notifications = LinkedHashMap() val cancelledNotifications = mutableListOf() + var notificationsEnabled = true fun reset() { notifications.clear() cancelledNotifications.clear() lastNotif = null lastNotifId = 0 + notificationsEnabled = true } private lateinit var mInstance: ShadowRoboNotificationManager @@ -117,6 +127,10 @@ class ShadowRoboNotificationManager : ShadowNotificationManager() { return notifications } + fun setShadowNotificationsEnabled(enabled: Boolean) { + mInstance.setNotificationsEnabled(enabled) + } + var lastChannel: NotificationChannel? = null var lastChannelGroup: NotificationChannelGroup? = null } diff --git a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt index b47959fce0..d8fa8ed86a 100644 --- a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt +++ b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt @@ -49,6 +49,7 @@ object MockHelper { configModel.opRepoPostCreateDelay = 1 configModel.opRepoPostCreateRetryUpTo = 1 configModel.opRepoDefaultFailRetryBackoff = 1 + configModel.foregroundFetchNotificationPermissionInterval = 1 configModel.appId = DEFAULT_APP_ID