diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d0759c5b..7e2b513d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + + // At the moment we are not handling permission results. Since this is a notification app + // expecting users to provide the notification permission for app to work + } + private var _binding: FragmentNotificationsBinding? = null private val binding get() = _binding!! @@ -135,7 +143,10 @@ class NotificationsScreen : Fragment(), NotificationsScreenUi { } .show() } - else -> throw IllegalArgumentException("Unknown view effect: $viewEffect") + + RequestNotificationPermissionViewEffect -> requestNotificationPermission() + + null -> throw NullPointerException() } } @@ -166,6 +177,11 @@ class NotificationsScreen : Fragment(), NotificationsScreenUi { adapter.submitList(null) } + @SuppressLint("InlinedApi") + private fun requestNotificationPermission() { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + private fun onToggleNotificationPinClicked(notification: PinnitNotification) { viewModel.dispatchEvent(TogglePinStatusClicked(notification)) } diff --git a/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEffect.kt b/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEffect.kt index cf78d627..d3eba803 100644 --- a/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEffect.kt +++ b/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEffect.kt @@ -22,3 +22,7 @@ data class CancelNotificationSchedule(val notificationId: UUID) : NotificationsS data class RemoveSchedule(val notificationId: UUID) : NotificationsScreenEffect() data class ScheduleNotification(val notification: PinnitNotification) : NotificationsScreenEffect() + +object CheckPermissionToPostNotification : NotificationsScreenEffect() + +object RequestNotificationPermission : NotificationsScreenEffect() diff --git a/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEffectHandler.kt b/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEffectHandler.kt index 06f623ad..48b142bc 100644 --- a/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEffectHandler.kt +++ b/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEffectHandler.kt @@ -44,9 +44,18 @@ class NotificationsScreenEffectHandler @AssistedInject constructor( is RemoveSchedule -> removeSchedule(effect, dispatchEvent) is ScheduleNotification -> pinnitNotificationScheduler.scheduleNotification(effect.notification) + + CheckPermissionToPostNotification -> checkPermissionToPostNotification(dispatchEvent) + + RequestNotificationPermission -> viewEffectConsumer.accept(RequestNotificationPermissionViewEffect) } } + private fun checkPermissionToPostNotification(dispatchEvent: (NotificationsScreenEvent) -> Unit) { + val canPostNotifications = notificationUtil.hasPermissionToPostNotifications() + dispatchEvent(HasPermissionToPostNotifications(canPostNotifications)) + } + private fun loadNotifications(dispatchEvent: (NotificationsScreenEvent) -> Unit) { val notificationsFlow = notificationRepository.notifications() notificationsFlow diff --git a/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEvent.kt b/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEvent.kt index 571a07cb..39500452 100644 --- a/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEvent.kt +++ b/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEvent.kt @@ -20,3 +20,5 @@ data class RemovedNotificationSchedule(val notificationId: UUID) : Notifications data class RemoveNotificationScheduleClicked(val notificationId: UUID) : NotificationsScreenEvent() data class RestoredDeletedNotification(val notification: PinnitNotification) : NotificationsScreenEvent() + +data class HasPermissionToPostNotifications(val canPostNotifications: Boolean) : NotificationsScreenEvent() diff --git a/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenInit.kt b/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenInit.kt index 55b5d0bb..f5009f3b 100644 --- a/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenInit.kt +++ b/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenInit.kt @@ -7,15 +7,15 @@ import javax.inject.Inject class NotificationsScreenInit @Inject constructor() : Init { override fun init(model: NotificationsScreenModel): First { - val effects = if (model.notificationsQueried.not()) { + val effects = mutableSetOf(CheckPermissionToPostNotification) + + if (model.notificationsQueried.not()) { // We are only checking for notifications visibility during // screen create because system notifications are disappear only // if the app is force closed (or when updating). So app needs // to be reopened again. Notifications will persist // orientation changes, so no point checking again. - setOf(LoadNotifications, CheckNotificationsVisibility) - } else { - emptySet() + effects.addAll(listOf(LoadNotifications, CheckNotificationsVisibility)) } return first(model, effects) diff --git a/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenUpdate.kt b/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenUpdate.kt index 319664af..f044eec9 100644 --- a/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenUpdate.kt +++ b/app/src/main/java/dev/sasikanth/pinnit/notifications/NotificationsScreenUpdate.kt @@ -3,6 +3,7 @@ package dev.sasikanth.pinnit.notifications import com.spotify.mobius.Next import com.spotify.mobius.Next.dispatch import com.spotify.mobius.Next.next +import com.spotify.mobius.Next.noChange import com.spotify.mobius.Update import javax.inject.Inject @@ -17,6 +18,15 @@ class NotificationsScreenUpdate @Inject constructor() : Update dispatch(setOf(CancelNotificationSchedule(event.notificationId))) is RemoveNotificationScheduleClicked -> dispatch(setOf(RemoveSchedule(event.notificationId))) is RestoredDeletedNotification -> dispatch(setOf(ScheduleNotification(event.notification))) + is HasPermissionToPostNotifications -> hasPermissionToPostNotifications(event.canPostNotifications) + } + } + + private fun hasPermissionToPostNotifications(canPostNotifications: Boolean): Next { + return if (!canPostNotifications) { + dispatch(setOf(RequestNotificationPermission)) + } else { + noChange() } } diff --git a/app/src/main/java/dev/sasikanth/pinnit/utils/notification/NotificationUtil.kt b/app/src/main/java/dev/sasikanth/pinnit/utils/notification/NotificationUtil.kt index d7502c36..4e5b0759 100644 --- a/app/src/main/java/dev/sasikanth/pinnit/utils/notification/NotificationUtil.kt +++ b/app/src/main/java/dev/sasikanth/pinnit/utils/notification/NotificationUtil.kt @@ -1,15 +1,18 @@ package dev.sasikanth.pinnit.utils.notification +import android.Manifest import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.Action import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.navigation.NavDeepLinkBuilder import dagger.hilt.android.qualifiers.ApplicationContext @@ -77,6 +80,15 @@ class NotificationUtil @Inject constructor( } } + /** + * Check if the app has permission to post notifications on devices running Android version >= 13 + */ + fun hasPermissionToPostNotifications() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } else { + true + } + private fun buildSystemNotification(notification: PinnitNotification): Notification { val content = notification.content.orEmpty() val editorScreenArgs = EditorScreenArgs( diff --git a/app/src/test/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEffectHandlerTest.kt b/app/src/test/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEffectHandlerTest.kt index 6467a148..88eedff3 100644 --- a/app/src/test/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEffectHandlerTest.kt +++ b/app/src/test/java/dev/sasikanth/pinnit/notifications/NotificationsScreenEffectHandlerTest.kt @@ -8,10 +8,8 @@ import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions import com.nhaarman.mockitokotlin2.whenever import com.spotify.mobius.Connection import com.spotify.mobius.test.RecordingConsumer -import dev.sasikanth.sharedtestcode.TestData import dev.sasikanth.pinnit.scheduler.PinnitNotificationScheduler import dev.sasikanth.pinnit.utils.TestDispatcherProvider -import dev.sasikanth.sharedtestcode.utils.TestUtcClock import dev.sasikanth.pinnit.utils.notification.NotificationUtil import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestCoroutineScope @@ -273,4 +271,29 @@ class NotificationsScreenEffectHandlerTest { verify(pinnitNotificationScheduler).scheduleNotification(notification) verifyNoMoreInteractions(pinnitNotificationScheduler) } + + @Test + fun `when check notifications permission effect is received, then check the permission`() { + // given + whenever(notificationUtil.hasPermissionToPostNotifications()) doReturn true + + // when + connection.accept(CheckPermissionToPostNotification) + + // then + consumer.assertValues(HasPermissionToPostNotifications(canPostNotifications = true)) + + verify(notificationUtil).hasPermissionToPostNotifications() + verifyNoMoreInteractions(notificationUtil) + } + + @Test + fun `when request notification permission effect is received, then request permission to post notifications`() { + // when + connection.accept(RequestNotificationPermission) + + // then + consumer.assertValues() + viewActionsConsumer.accept(RequestNotificationPermissionViewEffect) + } } diff --git a/app/src/test/java/dev/sasikanth/pinnit/notifications/NotificationsScreenInitTest.kt b/app/src/test/java/dev/sasikanth/pinnit/notifications/NotificationsScreenInitTest.kt index 46347c57..e4235647 100644 --- a/app/src/test/java/dev/sasikanth/pinnit/notifications/NotificationsScreenInitTest.kt +++ b/app/src/test/java/dev/sasikanth/pinnit/notifications/NotificationsScreenInitTest.kt @@ -21,7 +21,7 @@ class NotificationsScreenInitTest { .then( assertThatFirst( hasModel(defaultModel), - hasEffects(LoadNotifications, CheckNotificationsVisibility) + hasEffects(LoadNotifications, CheckNotificationsVisibility, CheckPermissionToPostNotification) ) ) } @@ -41,7 +41,7 @@ class NotificationsScreenInitTest { .then( assertThatFirst( hasModel(restoredModel), - hasNoEffects() + hasEffects(CheckPermissionToPostNotification) ) ) } diff --git a/app/src/test/java/dev/sasikanth/pinnit/notifications/NotificationsScreenUpdateTest.kt b/app/src/test/java/dev/sasikanth/pinnit/notifications/NotificationsScreenUpdateTest.kt index 012df837..4468497b 100644 --- a/app/src/test/java/dev/sasikanth/pinnit/notifications/NotificationsScreenUpdateTest.kt +++ b/app/src/test/java/dev/sasikanth/pinnit/notifications/NotificationsScreenUpdateTest.kt @@ -6,8 +6,6 @@ import com.spotify.mobius.test.NextMatchers.hasNoEffects import com.spotify.mobius.test.NextMatchers.hasNoModel import com.spotify.mobius.test.UpdateSpec import com.spotify.mobius.test.UpdateSpec.assertThatNext -import dev.sasikanth.sharedtestcode.TestData -import dev.sasikanth.sharedtestcode.utils.TestUtcClock import org.junit.Test import java.time.Instant import java.time.LocalDate @@ -188,4 +186,30 @@ class NotificationsScreenUpdateTest { ) ) } + + @Test + fun `when notifications permission is not granted, then request notifications permission`() { + updateSpec + .given(defaultModel) + .whenEvent(HasPermissionToPostNotifications(canPostNotifications = false)) + .then( + assertThatNext( + hasNoModel(), + hasEffects(RequestNotificationPermission) + ) + ) + } + + @Test + fun `when notifications permission is granted, then do nothing`() { + updateSpec + .given(defaultModel) + .whenEvent(HasPermissionToPostNotifications(canPostNotifications = true)) + .then( + assertThatNext( + hasNoModel(), + hasNoEffects() + ) + ) + } }