mirror of
https://github.com/anyproto/anytype-kotlin.git
synced 2025-06-08 05:47:05 +09:00
DROID-3654 Notifications | Notification alert (#2392)
This commit is contained in:
parent
be265520d4
commit
83e6981c81
12 changed files with 485 additions and 10 deletions
|
@ -42,6 +42,7 @@ dependencies {
|
|||
implementation libs.lifecycleViewModel
|
||||
implementation libs.lifecycleLiveData
|
||||
implementation libs.compose
|
||||
implementation libs.androidxCore
|
||||
|
||||
implementation libs.timber
|
||||
|
||||
|
|
|
@ -0,0 +1,114 @@
|
|||
package com.anytypeio.anytype.presentation.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface NotificationPermissionManager {
|
||||
fun shouldShowPermissionDialog(): Boolean
|
||||
fun onPermissionRequested()
|
||||
fun onPermissionGranted()
|
||||
fun onPermissionDenied()
|
||||
fun onPermissionDismissed()
|
||||
}
|
||||
|
||||
class NotificationPermissionManagerImpl @Inject constructor(
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
private val context: Context
|
||||
) : NotificationPermissionManager {
|
||||
private val _permissionState = MutableStateFlow<PermissionState>(PermissionState.NotRequested)
|
||||
val permissionState: StateFlow<PermissionState> = _permissionState
|
||||
|
||||
override fun shouldShowPermissionDialog(): Boolean {
|
||||
|
||||
// If notifications are already enabled at the system level, no dialog needed
|
||||
if (areNotificationsEnabled()) {
|
||||
return false
|
||||
}
|
||||
|
||||
val lastRequestTime = sharedPreferences.getLong(KEY_LAST_REQUEST_TIME, 0)
|
||||
val requestCount = sharedPreferences.getInt(KEY_REQUEST_COUNT, 0)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
return when {
|
||||
// First time request
|
||||
lastRequestTime == 0L -> true
|
||||
// User clicked "Not now" - show again after 24 hours, max 3 times
|
||||
requestCount < MAX_REQUEST_COUNT && (currentTime - lastRequestTime) >= HOURS_24 -> true
|
||||
// User rejected in system dialog - show banner in settings
|
||||
_permissionState.value == PermissionState.Denied -> false
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPermissionRequested() {
|
||||
val currentCount = sharedPreferences.getInt(KEY_REQUEST_COUNT, 0)
|
||||
sharedPreferences.edit().apply {
|
||||
putLong(KEY_LAST_REQUEST_TIME, System.currentTimeMillis())
|
||||
putInt(KEY_REQUEST_COUNT, currentCount + 1)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPermissionGranted() {
|
||||
_permissionState.value = PermissionState.Granted
|
||||
sharedPreferences.edit().apply {
|
||||
putBoolean(KEY_PERMISSION_GRANTED, true)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPermissionDenied() {
|
||||
_permissionState.value = PermissionState.Denied
|
||||
sharedPreferences.edit().apply {
|
||||
putBoolean(KEY_PERMISSION_GRANTED, false)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPermissionDismissed() {
|
||||
_permissionState.value = PermissionState.Dismissed
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if notifications can be posted:
|
||||
* - on API<33, checks whether the user has globally disabled notifications for your app
|
||||
* - on API>=33, also checks the new POST_NOTIFICATIONS runtime permission
|
||||
*/
|
||||
private fun areNotificationsEnabled(): Boolean {
|
||||
// 1) global switch
|
||||
val managerCompat = NotificationManagerCompat.from(context)
|
||||
if (!managerCompat.areNotificationsEnabled()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 2) on Tiramisu+, must also hold POST_NOTIFICATIONS
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
return context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
sealed class PermissionState {
|
||||
object NotRequested : PermissionState()
|
||||
object Granted : PermissionState()
|
||||
object Denied : PermissionState()
|
||||
object Dismissed : PermissionState()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_LAST_REQUEST_TIME = "notification_permission_last_request_time"
|
||||
private const val KEY_REQUEST_COUNT = "notification_permission_request_count"
|
||||
private const val KEY_PERMISSION_GRANTED = "notification_permission_granted"
|
||||
const val MAX_REQUEST_COUNT = 3
|
||||
private const val HOURS_24 = 24 * 60 * 60 * 1000L
|
||||
}
|
||||
}
|
|
@ -0,0 +1,171 @@
|
|||
package com.anytypeio.anytype.presentation.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.shadows.ShadowNotificationManager
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.P])
|
||||
class NotificationPermissionManagerImplTest {
|
||||
|
||||
private lateinit var sharedPreferences: SharedPreferences
|
||||
private val dispatcher = StandardTestDispatcher(name = "Default test dispatcher")
|
||||
private val testScope = TestScope(dispatcher)
|
||||
private lateinit var manager: NotificationPermissionManagerImpl
|
||||
|
||||
private lateinit var notificationManager: NotificationManager
|
||||
private lateinit var shadowNotificationManager: ShadowNotificationManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
// Use a real SharedPreferences from Robolectric
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
sharedPreferences = context.getSharedPreferences("test_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
// Clear shared preferences before each test
|
||||
sharedPreferences.edit().clear().apply()
|
||||
|
||||
// Grab NotificationManager and shadow it
|
||||
notificationManager =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
shadowNotificationManager = Shadows.shadowOf(notificationManager)
|
||||
|
||||
// By default, simulate notifications being DISABLED at the system level
|
||||
shadowNotificationManager.setNotificationsEnabled(false)
|
||||
|
||||
manager = NotificationPermissionManagerImpl(
|
||||
sharedPreferences = sharedPreferences,
|
||||
context = context
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
// Clean up coroutines
|
||||
testScope.cancel()
|
||||
// Clear shared preferences after each test
|
||||
sharedPreferences.edit().clear().apply()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should show dialog on first request`() = runTest {
|
||||
assertTrue(manager.shouldShowPermissionDialog())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not show dialog if notifications globally enabled`() = runTest {
|
||||
// Simulate user has notifications turned ON in OS settings
|
||||
shadowNotificationManager.setNotificationsEnabled(true)
|
||||
|
||||
assertFalse(manager.shouldShowPermissionDialog())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not show dialog if max requests reached`() = runTest {
|
||||
// Set up max requests
|
||||
repeat(NotificationPermissionManagerImpl.MAX_REQUEST_COUNT) {
|
||||
manager.onPermissionRequested()
|
||||
}
|
||||
|
||||
assertFalse(manager.shouldShowPermissionDialog())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should show dialog after 24 hours if request count less than max`() = runTest {
|
||||
// Set up initial request
|
||||
manager.onPermissionRequested()
|
||||
|
||||
// Simulate time passing
|
||||
val pastTime = System.currentTimeMillis() - HOURS_24 - 1000 // 24 hours + 1 second ago
|
||||
sharedPreferences.edit()
|
||||
.putLong(KEY_LAST_REQUEST_TIME, pastTime)
|
||||
.apply()
|
||||
|
||||
assertTrue(manager.shouldShowPermissionDialog())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not show dialog before 24 hours have passed`() = runTest {
|
||||
// Set up initial request
|
||||
manager.onPermissionRequested()
|
||||
|
||||
// Simulate time passing
|
||||
val recentTime = System.currentTimeMillis() - HOURS_24 + 1000 // 24 hours - 1 second ago
|
||||
sharedPreferences.edit()
|
||||
.putLong(KEY_LAST_REQUEST_TIME, recentTime)
|
||||
.apply()
|
||||
|
||||
assertFalse(manager.shouldShowPermissionDialog())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not show dialog when permission is denied`() = runTest {
|
||||
// First request permission to set up initial state
|
||||
manager.onPermissionRequested()
|
||||
// Then deny it
|
||||
manager.onPermissionDenied()
|
||||
|
||||
assertEquals(NotificationPermissionManagerImpl.PermissionState.Denied, manager.permissionState.first())
|
||||
assertFalse(manager.shouldShowPermissionDialog())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onPermissionRequested should increment request count and update timestamp`() = runTest {
|
||||
val initialCount = sharedPreferences.getInt(KEY_REQUEST_COUNT, 0)
|
||||
manager.onPermissionRequested()
|
||||
|
||||
val newCount = sharedPreferences.getInt(KEY_REQUEST_COUNT, 0)
|
||||
assertEquals(initialCount + 1, newCount)
|
||||
assertTrue(sharedPreferences.contains(KEY_LAST_REQUEST_TIME))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onPermissionGranted should update state and save to preferences`() = runTest {
|
||||
manager.onPermissionGranted()
|
||||
|
||||
assertEquals(NotificationPermissionManagerImpl.PermissionState.Granted, manager.permissionState.first())
|
||||
assertTrue(sharedPreferences.getBoolean(KEY_PERMISSION_GRANTED, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onPermissionDenied should update state and save to preferences`() = runTest {
|
||||
manager.onPermissionDenied()
|
||||
|
||||
assertEquals(NotificationPermissionManagerImpl.PermissionState.Denied, manager.permissionState.first())
|
||||
assertFalse(sharedPreferences.getBoolean(KEY_PERMISSION_GRANTED, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onPermissionDismissed should update state`() = runTest {
|
||||
manager.onPermissionDismissed()
|
||||
|
||||
assertEquals(NotificationPermissionManagerImpl.PermissionState.Dismissed, manager.permissionState.first())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_LAST_REQUEST_TIME = "notification_permission_last_request_time"
|
||||
private const val KEY_REQUEST_COUNT = "notification_permission_request_count"
|
||||
private const val KEY_PERMISSION_GRANTED = "notification_permission_granted"
|
||||
private const val HOURS_24 = 24 * 60 * 60 * 1000L
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue