1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-07 21:37:02 +09:00

DROID-3654 Notifications | Notification alert (#2392)

This commit is contained in:
Konstantin Ivanov 2025-05-20 14:55:28 +02:00 committed by GitHub
parent be265520d4
commit 83e6981c81
Signed by: github
GPG key ID: B5690EEEBB952194
12 changed files with 485 additions and 10 deletions

View file

@ -22,6 +22,7 @@ import com.anytypeio.anytype.domain.workspace.SpaceManager
import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel
import com.anytypeio.anytype.feature_chats.presentation.ChatViewModelFactory
import com.anytypeio.anytype.middleware.EventProxy
import com.anytypeio.anytype.presentation.notifications.NotificationPermissionManager
import com.anytypeio.anytype.presentation.util.CopyFileToCacheDirectory
import com.anytypeio.anytype.presentation.util.DefaultCopyFileToCacheDirectory
import com.anytypeio.anytype.presentation.vault.ExitToVaultDelegate
@ -99,4 +100,5 @@ interface ChatComponentDependencies : ComponentDependencies {
fun storeOfObjectTypes(): StoreOfObjectTypes
fun context(): Context
fun spaceManager(): SpaceManager
fun notificationPermissionManager(): NotificationPermissionManager
}

View file

@ -20,6 +20,8 @@ import com.anytypeio.anytype.presentation.notifications.CryptoService
import com.anytypeio.anytype.presentation.notifications.CryptoServiceImpl
import com.anytypeio.anytype.presentation.notifications.DecryptionPushContentService
import com.anytypeio.anytype.presentation.notifications.DecryptionPushContentServiceImpl
import com.anytypeio.anytype.presentation.notifications.NotificationPermissionManager
import com.anytypeio.anytype.presentation.notifications.NotificationPermissionManagerImpl
import com.anytypeio.anytype.presentation.notifications.NotificationsProvider
import com.anytypeio.anytype.presentation.notifications.PushKeyProvider
import com.anytypeio.anytype.presentation.notifications.PushKeyProviderImpl
@ -129,4 +131,17 @@ object NotificationsModule {
pushKeyProvider = pushKeyProvider,
cryptoService = cryptoService,
)
@Provides
@Singleton
@JvmStatic
fun provideNotificationPermissionManager(
@Named("default") prefs: SharedPreferences,
context: Context
): NotificationPermissionManager {
return NotificationPermissionManagerImpl(
sharedPreferences = prefs,
context = context
)
}
}

View file

@ -1,10 +1,14 @@
package com.anytypeio.anytype.ui.chats
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@ -35,6 +39,7 @@ import com.anytypeio.anytype.core_models.primitives.SpaceId
import com.anytypeio.anytype.core_utils.ext.arg
import com.anytypeio.anytype.core_utils.ext.toast
import com.anytypeio.anytype.core_utils.intents.SystemAction
import com.anytypeio.anytype.core_utils.intents.SystemAction.*
import com.anytypeio.anytype.core_utils.intents.proceedWithAction
import com.anytypeio.anytype.core_utils.ui.BaseComposeFragment
import com.anytypeio.anytype.di.common.componentManager
@ -43,6 +48,7 @@ import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel
import com.anytypeio.anytype.feature_chats.presentation.ChatViewModelFactory
import com.anytypeio.anytype.feature_chats.ui.ChatScreenWrapper
import com.anytypeio.anytype.feature_chats.ui.ChatTopToolbar
import com.anytypeio.anytype.feature_chats.ui.NotificationPermissionContent
import com.anytypeio.anytype.presentation.home.OpenObjectNavigation
import com.anytypeio.anytype.presentation.search.GlobalSearchViewModel
import com.anytypeio.anytype.ui.editor.EditorFragment
@ -79,7 +85,11 @@ class ChatFragment : BaseComposeFragment() {
setContent {
MaterialTheme(typography = typography) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val notificationsSheetState =
rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showGlobalSearchBottomSheet by remember { mutableStateOf(false) }
val showNotificationPermissionDialog =
vm.showNotificationPermissionDialog.collectAsStateWithLifecycle().value
Column(
modifier = Modifier
@ -106,6 +116,34 @@ class ChatFragment : BaseComposeFragment() {
)
}
if (showNotificationPermissionDialog) {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
Timber.d("Permission granted: $isGranted")
if (isGranted) {
vm.onNotificationPermissionGranted()
} else {
vm.onNotificationPermissionDenied()
}
}
ModalBottomSheet(
onDismissRequest = { vm.onNotificationPermissionDismissed() },
sheetState = notificationsSheetState,
containerColor = colorResource(id = R.color.background_secondary),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
dragHandle = null
) {
NotificationPermissionContent(
onCancelClicked = { vm.onNotificationPermissionDismissed() },
onEnableNotifications = {
vm.onNotificationPermissionRequested()
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
)
}
}
if (showGlobalSearchBottomSheet) {
ModalBottomSheet(
onDismissRequest = {
@ -128,8 +166,7 @@ class ChatFragment : BaseComposeFragment() {
modifier = Modifier.padding(top = 12.dp),
state = searchViewModel.state
.collectAsStateWithLifecycle()
.value
,
.value,
onQueryChanged = searchViewModel::onQueryChanged,
onObjectClicked = {
vm.onAttachObject(it)
@ -144,7 +181,7 @@ class ChatFragment : BaseComposeFragment() {
}
LaunchedEffect(Unit) {
vm.navigation.collect { nav ->
when(nav) {
when (nav) {
is OpenObjectNavigation.OpenEditor -> {
runCatching {
findNavController().navigate(
@ -177,7 +214,8 @@ class ChatFragment : BaseComposeFragment() {
}
LaunchedEffect(Unit) {
vm.commands.collect { command ->
when(command) {
Timber.d("Command: $command")
when (command) {
is ChatViewModel.ViewModelCommand.Exit -> {
runCatching {
findNavController().popBackStack()
@ -254,7 +292,7 @@ class ChatFragment : BaseComposeFragment() {
is ChatViewModel.ViewModelCommand.Browse -> {
runCatching {
proceedWithAction(
SystemAction.OpenUrl(
OpenUrl(
command.url
)
)
@ -270,6 +308,9 @@ class ChatFragment : BaseComposeFragment() {
isSpaceRoot = isSpaceRootScreen()
)
}
LaunchedEffect(Unit) {
vm.checkNotificationPermissionDialogState()
}
}
}
}
@ -300,6 +341,7 @@ class ChatFragment : BaseComposeFragment() {
companion object {
private const val CTX_KEY = "arg.discussion.ctx"
private const val SPACE_KEY = "arg.discussion.space"
const val PERMISSIONS_REQUEST_CODE = 100
fun args(
space: Id,
ctx: Id

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View file

@ -4,7 +4,6 @@ import androidx.lifecycle.viewModelScope
import com.anytypeio.anytype.core_models.Block
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.LinkPreview
import com.anytypeio.anytype.core_models.ObjectType
import com.anytypeio.anytype.core_models.ObjectTypeUniqueKeys
import com.anytypeio.anytype.core_models.ObjectWrapper
@ -42,6 +41,7 @@ import com.anytypeio.anytype.presentation.confgs.ChatConfig
import com.anytypeio.anytype.presentation.home.OpenObjectNavigation
import com.anytypeio.anytype.presentation.home.navigation
import com.anytypeio.anytype.presentation.mapper.objectIcon
import com.anytypeio.anytype.presentation.notifications.NotificationPermissionManager
import com.anytypeio.anytype.presentation.objects.ObjectIcon
import com.anytypeio.anytype.presentation.objects.SpaceMemberIconView
import com.anytypeio.anytype.presentation.search.GlobalSearchItemView
@ -61,7 +61,6 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
@ -83,7 +82,8 @@ class ChatViewModel @Inject constructor(
private val copyFileToCacheDirectory: CopyFileToCacheDirectory,
private val exitToVaultDelegate: ExitToVaultDelegate,
private val getLinkPreview: GetLinkPreview,
private val createObjectFromUrl: CreateObjectFromUrl
private val createObjectFromUrl: CreateObjectFromUrl,
private val notificationPermissionManager: NotificationPermissionManager
) : BaseViewModel(), ExitToVaultDelegate by exitToVaultDelegate {
private val visibleRangeUpdates = MutableSharedFlow<Pair<Id, Id>>(
@ -100,6 +100,7 @@ class ChatViewModel @Inject constructor(
val navigation = MutableSharedFlow<OpenObjectNavigation>()
val chatBoxMode = MutableStateFlow<ChatBoxMode>(ChatBoxMode.Default())
val mentionPanelState = MutableStateFlow<MentionPanelState>(MentionPanelState.Hidden)
val showNotificationPermissionDialog = MutableStateFlow(false)
private val dateFormatter = SimpleDateFormat("d MMMM YYYY")
@ -955,6 +956,33 @@ class ChatViewModel @Inject constructor(
}
}
fun checkNotificationPermissionDialogState() {
val shouldShow = notificationPermissionManager.shouldShowPermissionDialog()
Timber.d("shouldShowNotificationPermissionDialog: $shouldShow")
if (shouldShow) {
showNotificationPermissionDialog.value = true
}
}
fun onNotificationPermissionRequested() {
notificationPermissionManager.onPermissionRequested()
}
fun onNotificationPermissionGranted() {
showNotificationPermissionDialog.value = false
notificationPermissionManager.onPermissionGranted()
}
fun onNotificationPermissionDenied() {
showNotificationPermissionDialog.value = false
notificationPermissionManager.onPermissionDenied()
}
fun onNotificationPermissionDismissed() {
showNotificationPermissionDialog.value = false
notificationPermissionManager.onPermissionDismissed()
}
private fun isMentionTriggered(text: String, selectionStart: Int): Boolean {
if (selectionStart <= 0 || selectionStart > text.length) return false
val previousChar = text[selectionStart - 1]

View file

@ -18,6 +18,7 @@ import com.anytypeio.anytype.domain.`object`.OpenObject
import com.anytypeio.anytype.domain.`object`.SetObjectDetails
import com.anytypeio.anytype.domain.objects.CreateObjectFromUrl
import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes
import com.anytypeio.anytype.presentation.notifications.NotificationPermissionManager
import com.anytypeio.anytype.presentation.util.CopyFileToCacheDirectory
import com.anytypeio.anytype.presentation.vault.ExitToVaultDelegate
import javax.inject.Inject
@ -39,7 +40,8 @@ class ChatViewModelFactory @Inject constructor(
private val copyFileToCacheDirectory: CopyFileToCacheDirectory,
private val exitToVaultDelegate: ExitToVaultDelegate,
private val getLinkPreview: GetLinkPreview,
private val createObjectFromUrl: CreateObjectFromUrl
private val createObjectFromUrl: CreateObjectFromUrl,
private val notificationPermissionManager: NotificationPermissionManager
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = ChatViewModel(
@ -59,6 +61,7 @@ class ChatViewModelFactory @Inject constructor(
copyFileToCacheDirectory = copyFileToCacheDirectory,
exitToVaultDelegate = exitToVaultDelegate,
getLinkPreview = getLinkPreview,
createObjectFromUrl = createObjectFromUrl
createObjectFromUrl = createObjectFromUrl,
notificationPermissionManager = notificationPermissionManager
) as T
}

View file

@ -0,0 +1,94 @@
package com.anytypeio.anytype.feature_chats.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_ui.common.DefaultPreviews
import com.anytypeio.anytype.core_ui.views.ButtonPrimary
import com.anytypeio.anytype.core_ui.views.ButtonSecondary
import com.anytypeio.anytype.core_ui.views.ButtonSize
import com.anytypeio.anytype.core_ui.views.HeadlineHeading
import com.anytypeio.anytype.core_ui.views.Title2
import com.anytypeio.anytype.feature_chats.R
@Composable
fun NotificationPermissionContent(
onEnableNotifications: () -> Unit,
onCancelClicked: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(color = colorResource(id = R.color.widget_background)),
) {
Image(
modifier = Modifier
.fillMaxWidth()
.height(232.dp),
painter = painterResource(id = R.drawable.push_modal_illustration),
contentDescription = "Push notifications illustration",
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = stringResource(R.string.notifications_modal_title),
style = HeadlineHeading,
textAlign = TextAlign.Center,
color = colorResource(id = R.color.text_primary),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.notifications_modal_description),
style = Title2,
textAlign = TextAlign.Center,
color = colorResource(id = R.color.text_primary),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(20.dp))
ButtonPrimary(
text = stringResource(R.string.notifications_modal_success_button),
onClick = onEnableNotifications,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
size = ButtonSize.Large,
)
Spacer(modifier = Modifier.height(10.dp))
ButtonSecondary(
text = stringResource(id = R.string.notifications_modal_cancel_button),
onClick = onCancelClicked,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
size = ButtonSize.Large
)
Spacer(modifier = Modifier.height(16.dp))
}
}
@DefaultPreviews
@Composable
fun NotificationPermissionRequestDialogPreview() {
NotificationPermissionContent(
onEnableNotifications = {},
onCancelClicked = {}
)
}

View file

@ -2052,4 +2052,9 @@ Please provide specific details of your needs here.</string>
<string name="onboarding_email_error">Incorrect email</string>
<string name="onboarding_name_error">Enter name</string>
<string name="notifications_modal_title">Turn on push notifications</string>
<string name="notifications_modal_description">Get notified instantly when someone messages or mentions you in your spaces.</string>
<string name="notifications_modal_success_button">Enable notifications</string>
<string name="notifications_modal_cancel_button">Not now</string>
</resources>

View file

@ -42,6 +42,7 @@ dependencies {
implementation libs.lifecycleViewModel
implementation libs.lifecycleLiveData
implementation libs.compose
implementation libs.androidxCore
implementation libs.timber

View file

@ -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
}
}

View file

@ -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
}
}