Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Poll for notification permission changes #2112

Merged
merged 7 commits into from
Jun 14, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -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
jkasten2 marked this conversation as resolved.
Show resolved Hide resolved
* 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,17 +45,26 @@ 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(
private val _application: IApplicationService,
private val _requestPermission: IRequestPermissionService,
private val _applicationService: IApplicationService,
private val _preferenceService: IPreferencesService,
private val _configModelStore: ConfigModelStore,
) : IRequestPermissionService.PermissionCallback,
INotificationPermissionController {
private val waiter = WaiterWithValue<Boolean>()
private val pollingWaiter = Waiter()
private var pollingWaitInterval: Long
private val events = EventProducer<INotificationPermissionChangedHandler>()
private var enabled: Boolean
private val coroutineScope = CoroutineScope(newSingleThreadContext(name = "NotificationPermissionController"))

override val canRequestPermission: Boolean
get() =
Expand All @@ -64,14 +75,57 @@ internal class NotificationPermissionController(
)!!

init {
this.enabled = notificationsEnabled()
jkasten2 marked this conversation as resolved.
Show resolved Hide resolved
_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)
val supportsNativePrompt =
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
Expand Down Expand Up @@ -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) {
Expand All @@ -132,8 +185,7 @@ internal class NotificationPermissionController(
}

if (!fallbackShown) {
waiter.wake(false)
events.fire { it.onNotificationPermissionChanged(false) }
permissionPromptCompleted(false)
}
}

Expand All @@ -154,17 +206,15 @@ 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)
}
},
)
NavigateToAndroidSettingsForNotifications.show(activity)
}

override fun onDecline() {
waiter.wake(false)
events.fire { it.onNotificationPermissionChanged(false) }
permissionPromptCompleted(false)
}
},
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
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()
}

test("NotificationPermissionController permission polling fires permission changed event") {
// Given
val mockRequestPermissionService = mockk<IRequestPermissionService>()
every { mockRequestPermissionService.registerAsCallback(any(), any()) } just runs
val mockPreferenceService = mockk<IPreferencesService>()
val focusHandlerList = mutableListOf<IApplicationLifecycleHandler>()
val mockAppService = mockk<IApplicationService>()
every { mockAppService.addApplicationLifecycleHandler(any()) } answers {
focusHandlerList.add(firstArg<IApplicationLifecycleHandler>())
}
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.setNotificationsEnabled(false)
delay(5)

// Then
// permissionChanged Event should fire
handlerFired shouldBe true
}

test("NotificationPermissionController permission polling pauses when app loses") {
// Given
val mockRequestPermissionService = mockk<IRequestPermissionService>()
every { mockRequestPermissionService.registerAsCallback(any(), any()) } just runs
val mockPreferenceService = mockk<IPreferencesService>()
val handlerList = mutableListOf<IApplicationLifecycleHandler>()
val mockAppService = mockk<IApplicationService>()
every { mockAppService.addApplicationLifecycleHandler(any()) } answers {
handlerList.add(firstArg<IApplicationLifecycleHandler>())
}
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(5)
// the permission changes
ShadowRoboNotificationManager.setNotificationsEnabled(false)
delay(5)

// Then
// permissionChanged Event should not fire
handlerFired shouldBe false
}

test("NotificationPermissionController permission polling resumes when app gains focus") {
// Given
val mockRequestPermissionService = mockk<IRequestPermissionService>()
every { mockRequestPermissionService.registerAsCallback(any(), any()) } just runs
val mockPreferenceService = mockk<IPreferencesService>()
val handlerList = mutableListOf<IApplicationLifecycleHandler>()
val mockAppService = mockk<IApplicationService>()
every { mockAppService.addApplicationLifecycleHandler(any()) } answers {
handlerList.add(firstArg<IApplicationLifecycleHandler>())
}
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(5)
// the permission changes
ShadowRoboNotificationManager.setNotificationsEnabled(false)
delay(5)
// the app regains focus
for (handler in handlerList) {
handler.onFocus()
}
delay(5)

// Then
// permissionChanged Event should fire
handlerFired shouldBe true
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ class ShadowRoboNotificationManager : ShadowNotificationManager() {
return notifications
}

fun setNotificationsEnabled(enabled: Boolean) {
mInstance.setNotificationsEnabled(enabled)
}

var lastChannel: NotificationChannel? = null
var lastChannelGroup: NotificationChannelGroup? = null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ object MockHelper {
configModel.opRepoPostCreateDelay = 1
configModel.opRepoPostCreateRetryUpTo = 1
configModel.opRepoDefaultFailRetryBackoff = 1
configModel.foregroundFetchNotificationPermissionInterval = 1

configModel.appId = DEFAULT_APP_ID

Expand Down
Loading