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:
parent
be265520d4
commit
83e6981c81
12 changed files with 485 additions and 10 deletions
|
@ -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
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
BIN
core-ui/src/main/res/drawable-night/push_modal_illustration.png
Normal file
BIN
core-ui/src/main/res/drawable-night/push_modal_illustration.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 60 KiB |
BIN
core-ui/src/main/res/drawable/push_modal_illustration.png
Normal file
BIN
core-ui/src/main/res/drawable/push_modal_illustration.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 51 KiB |
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
|
@ -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>
|
|
@ -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