diff --git a/app/src/main/java/com/anytypeio/anytype/di/feature/chats/ChatsDI.kt b/app/src/main/java/com/anytypeio/anytype/di/feature/chats/ChatsDI.kt index 90c2c4668f..8ea4385221 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/feature/chats/ChatsDI.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/feature/chats/ChatsDI.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/di/main/NotificationsModule.kt b/app/src/main/java/com/anytypeio/anytype/di/main/NotificationsModule.kt index 029369913f..859977804a 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/main/NotificationsModule.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/main/NotificationsModule.kt @@ -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 + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/ui/chats/ChatFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/chats/ChatFragment.kt index 7ca853a84b..a220519e62 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/chats/ChatFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/chats/ChatFragment.kt @@ -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 diff --git a/core-ui/src/main/res/drawable-night/push_modal_illustration.png b/core-ui/src/main/res/drawable-night/push_modal_illustration.png new file mode 100644 index 0000000000..62ca2b59e1 Binary files /dev/null and b/core-ui/src/main/res/drawable-night/push_modal_illustration.png differ diff --git a/core-ui/src/main/res/drawable/push_modal_illustration.png b/core-ui/src/main/res/drawable/push_modal_illustration.png new file mode 100644 index 0000000000..b754014ded Binary files /dev/null and b/core-ui/src/main/res/drawable/push_modal_illustration.png differ diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModel.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModel.kt index 6a64bc0682..b09ebd9db4 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModel.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModel.kt @@ -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>( @@ -100,6 +100,7 @@ class ChatViewModel @Inject constructor( val navigation = MutableSharedFlow() val chatBoxMode = MutableStateFlow(ChatBoxMode.Default()) val mentionPanelState = MutableStateFlow(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] diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModelFactory.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModelFactory.kt index 0735a55078..72ad71dc2c 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModelFactory.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModelFactory.kt @@ -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 create(modelClass: Class): T = ChatViewModel( @@ -59,6 +61,7 @@ class ChatViewModelFactory @Inject constructor( copyFileToCacheDirectory = copyFileToCacheDirectory, exitToVaultDelegate = exitToVaultDelegate, getLinkPreview = getLinkPreview, - createObjectFromUrl = createObjectFromUrl + createObjectFromUrl = createObjectFromUrl, + notificationPermissionManager = notificationPermissionManager ) as T } \ No newline at end of file diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/PushPermissions.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/PushPermissions.kt new file mode 100644 index 0000000000..61af5f6692 --- /dev/null +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/PushPermissions.kt @@ -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 = {} + ) +} \ No newline at end of file diff --git a/localization/src/main/res/values/strings.xml b/localization/src/main/res/values/strings.xml index 36419e39f3..b0586c0230 100644 --- a/localization/src/main/res/values/strings.xml +++ b/localization/src/main/res/values/strings.xml @@ -2052,4 +2052,9 @@ Please provide specific details of your needs here. Incorrect email Enter name + Turn on push notifications + Get notified instantly when someone messages or mentions you in your spaces. + Enable notifications + Not now + \ No newline at end of file diff --git a/presentation/build.gradle b/presentation/build.gradle index be102e790d..e6e18b71fe 100644 --- a/presentation/build.gradle +++ b/presentation/build.gradle @@ -42,6 +42,7 @@ dependencies { implementation libs.lifecycleViewModel implementation libs.lifecycleLiveData implementation libs.compose + implementation libs.androidxCore implementation libs.timber diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/notifications/NotificationPermissionManager.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/notifications/NotificationPermissionManager.kt new file mode 100644 index 0000000000..2aef951394 --- /dev/null +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/notifications/NotificationPermissionManager.kt @@ -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.NotRequested) + val permissionState: StateFlow = _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 + } +} \ No newline at end of file diff --git a/presentation/src/test/java/com/anytypeio/anytype/presentation/notifications/NotificationPermissionManagerImplTest.kt b/presentation/src/test/java/com/anytypeio/anytype/presentation/notifications/NotificationPermissionManagerImplTest.kt new file mode 100644 index 0000000000..362f4e90ff --- /dev/null +++ b/presentation/src/test/java/com/anytypeio/anytype/presentation/notifications/NotificationPermissionManagerImplTest.kt @@ -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() + 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 + } +} \ No newline at end of file