Skip to content

Commit

Permalink
Merge pull request #2112 from OneSignal/fix/poll_for_notification_per…
Browse files Browse the repository at this point in the history
…mission

Poll for notification permission changes
  • Loading branch information
emawby authored Jun 14, 2024
2 parents d4a2a69 + 99d3094 commit 91fc398
Show file tree
Hide file tree
Showing 5 changed files with 260 additions and 9 deletions.
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
* 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()
_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,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<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.setShadowNotificationsEnabled(false)
delay(100)

// 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(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<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(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
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -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?)
Expand All @@ -97,12 +105,14 @@ class ShadowRoboNotificationManager : ShadowNotificationManager() {
var lastNotifId = 0
val notifications = LinkedHashMap<Int, PostedNotification>()
val cancelledNotifications = mutableListOf<Int>()
var notificationsEnabled = true

fun reset() {
notifications.clear()
cancelledNotifications.clear()
lastNotif = null
lastNotifId = 0
notificationsEnabled = true
}

private lateinit var mInstance: ShadowRoboNotificationManager
Expand All @@ -117,6 +127,10 @@ class ShadowRoboNotificationManager : ShadowNotificationManager() {
return notifications
}

fun setShadowNotificationsEnabled(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

0 comments on commit 91fc398

Please sign in to comment.