From 284e89d20d4d967aa528e1c6621d81f2b5873f6d Mon Sep 17 00:00:00 2001 From: Tobias Preuss Date: Fri, 26 Jan 2024 21:38:05 +0100 Subject: [PATCH 01/10] Document alarm notification behavior with disabled notification channels. --- .../fahrplan/congress/notifications/NotificationHelper.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/notifications/NotificationHelper.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/notifications/NotificationHelper.kt index a63a1cb230..639adebba9 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/notifications/NotificationHelper.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/notifications/NotificationHelper.kt @@ -18,6 +18,11 @@ internal class NotificationHelper(context: Context) : ContextWrapper(context) { getNotificationManager() } + /** + * Returns `true` if notifications in general are enabled for the app. + * If individual channels are disabled, this method still returns `true`. + * Hence, firing an alarm for a disabled channel will not show a notification. + */ val notificationsEnabled: Boolean get() = notificationManager.areNotificationsEnabled() From 888128e7b56c13e89ef7133c976661566ff83ccf Mon Sep 17 00:00:00 2001 From: Tobias Preuss Date: Fri, 26 Jan 2024 21:40:42 +0100 Subject: [PATCH 02/10] Improve error message with disabled notifications on devices running < Android 13. + Clarify, why the SDK check is needed. + Enhance test descriptions. + Related: 63b57b53b279f7660c50e64e288001a9abc8c6aa. --- .../fahrplan/congress/details/SessionDetailsFragment.kt | 8 ++++++-- .../fahrplan/congress/details/SessionDetailsViewModel.kt | 9 +++++---- .../fahrplan/congress/schedule/FahrplanFragment.kt | 8 ++++++-- .../fahrplan/congress/schedule/FahrplanViewModel.kt | 8 +++++--- app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../congress/details/SessionDetailsViewModelTest.kt | 6 +++--- .../fahrplan/congress/schedule/FahrplanViewModelTest.kt | 6 +++--- 8 files changed, 30 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt index 059393b891..3bd45fe5e1 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt @@ -214,8 +214,8 @@ class SessionDetailsFragment : Fragment() { viewModel.requestPostNotificationsPermission.observe(viewLifecycleOwner) { permissionRequestLauncher.launch(POST_NOTIFICATIONS) } - viewModel.missingPostNotificationsPermission.observe(viewLifecycleOwner) { - showMissingPostNotificationsPermissionError() + viewModel.notificationsDisabled.observe(viewLifecycleOwner) { + showNotificationsDisabledError() } } @@ -363,6 +363,10 @@ class SessionDetailsFragment : Fragment() { Toast.makeText(requireContext(), R.string.alarms_disabled_notifications_permission_missing, Toast.LENGTH_LONG).show() } + private fun showNotificationsDisabledError() { + Toast.makeText(requireContext(), R.string.alarms_disabled_notifications_are_disabled, Toast.LENGTH_LONG).show() + } + private fun updateOptionsMenu() { requireActivity().invalidateOptionsMenu() } diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModel.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModel.kt index 87e2151d39..ae36f75fa8 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModel.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModel.kt @@ -108,9 +108,8 @@ internal class SessionDetailsViewModel( private val mutableRequestPostNotificationsPermission = Channel() val requestPostNotificationsPermission = mutableRequestPostNotificationsPermission.receiveAsFlow() - private val mutableMissingPostNotificationsPermission = Channel() - val missingPostNotificationsPermission = - mutableMissingPostNotificationsPermission.receiveAsFlow() + private val mutableNotificationsDisabled = Channel() + val notificationsDisabled = mutableNotificationsDisabled.receiveAsFlow() private fun SelectedSessionParameter.customizeEngelsystemRoomName() = copy( roomName = if (roomName == defaultEngelsystemRoomName) customEngelsystemRoomName else roomName @@ -211,9 +210,11 @@ internal class SessionDetailsViewModel( if (notificationHelper.notificationsEnabled) { mutableSetAlarm.sendOneTimeEvent(Unit) } else { + // Check runtime version here because requesting the POST_NOTIFICATION permission + // before Android 13 (Tiramisu) has no effect nor error message. when (runsAtLeastOnAndroidTiramisu) { true -> mutableRequestPostNotificationsPermission.sendOneTimeEvent(Unit) - false -> mutableMissingPostNotificationsPermission.sendOneTimeEvent(Unit) + false -> mutableNotificationsDisabled.sendOneTimeEvent(Unit) } } } diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt index 2a561a3a61..ab55d7a778 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt @@ -216,8 +216,8 @@ class FahrplanFragment : Fragment(), SessionViewEventsHandler { viewModel.requestPostNotificationsPermission.observe(viewLifecycleOwner) { permissionRequestLauncher.launch(POST_NOTIFICATIONS) } - viewModel.missingPostNotificationsPermission.observe(viewLifecycleOwner) { - showMissingPostNotificationsPermissionError() + viewModel.notificationsDisabled.observe(viewLifecycleOwner) { + showNotificationsDisabledError() } viewModel.showAlarmTimePicker.observe(viewLifecycleOwner) { showAlarmTimePicker() @@ -553,6 +553,10 @@ class FahrplanFragment : Fragment(), SessionViewEventsHandler { Toast.makeText(requireContext(), R.string.alarms_disabled_notifications_permission_missing, Toast.LENGTH_LONG).show() } + private fun showNotificationsDisabledError() { + Toast.makeText(requireContext(), R.string.alarms_disabled_notifications_are_disabled, Toast.LENGTH_LONG).show() + } + private inner class OnDaySelectedListener(private val numDays: Int) : OnNavigationListener { private var isSynthetic = true diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModel.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModel.kt index 2d064733ad..da41cabad3 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModel.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModel.kt @@ -85,8 +85,8 @@ internal class FahrplanViewModel( private val mutableRequestPostNotificationsPermission = Channel() val requestPostNotificationsPermission = mutableRequestPostNotificationsPermission.receiveAsFlow() - private val mutableMissingPostNotificationsPermission = Channel() - val missingPostNotificationsPermission = mutableMissingPostNotificationsPermission.receiveAsFlow() + private val mutableNotificationsDisabled = Channel() + val notificationsDisabled = mutableNotificationsDisabled.receiveAsFlow() private val mutableShowAlarmTimePicker = Channel() val showAlarmTimePicker = mutableShowAlarmTimePicker.receiveAsFlow() @@ -101,9 +101,11 @@ internal class FahrplanViewModel( if (notificationHelper.notificationsEnabled) { mutableShowAlarmTimePicker.sendOneTimeEvent(Unit) } else { + // Check runtime version here because requesting the POST_NOTIFICATION permission + // before Android 13 (Tiramisu) has no effect nor error message. when (runsAtLeastOnAndroidTiramisu) { true -> mutableRequestPostNotificationsPermission.sendOneTimeEvent(Unit) - false -> mutableMissingPostNotificationsPermission.sendOneTimeEvent(Unit) + false -> mutableNotificationsDisabled.sendOneTimeEvent(Unit) } } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c9d1330ebb..1a26766714 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -203,6 +203,7 @@ 60 Minuten vorher Alarme sind deaktiviert. Bitte erteile die \"Benachrichtigungen\" Berechtigung. + Alarme sind deaktiviert. Bitte aktiviere Benachrichtigungen. %d min. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3e0bd067d2..ff4a03c223 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -234,6 +234,7 @@ 60 minutes before Alarms are disabled. Please grant the \"Notifications\" permission. + Alarms are disabled. Please enable notifications. %d min. diff --git a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModelTest.kt b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModelTest.kt index 9aaaca6a7a..2615d7dfb1 100644 --- a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModelTest.kt +++ b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModelTest.kt @@ -318,7 +318,7 @@ class SessionDetailsViewModelTest { } @Test - fun `setAlarm() posts to requestPostNotificationsPermission`() = runTest { + fun `setAlarm() posts to requestPostNotificationsPermission as of Android 13`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn false } @@ -335,7 +335,7 @@ class SessionDetailsViewModelTest { } @Test - fun `setAlarm() posts to missingPostNotificationsPermission`() = runTest { + fun `setAlarm() posts to notificationsDisabled before Android 13`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn false } @@ -346,7 +346,7 @@ class SessionDetailsViewModelTest { runsAtLeastOnAndroidTiramisu = false ) viewModel.setAlarm() - viewModel.missingPostNotificationsPermission.test { + viewModel.notificationsDisabled.test { assertThat(awaitItem()).isEqualTo(Unit) } } diff --git a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModelTest.kt b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModelTest.kt index 871c3598d5..27035c7dc0 100644 --- a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModelTest.kt +++ b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModelTest.kt @@ -270,7 +270,7 @@ class FahrplanViewModelTest { } @Test - fun `showAlarmTimePickerWithChecks() posts to requestPostNotificationsPermission`() = runTest { + fun `showAlarmTimePickerWithChecks() posts to requestPostNotificationsPermission as of Android 13`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn false } @@ -287,7 +287,7 @@ class FahrplanViewModelTest { } @Test - fun `showAlarmTimePickerWithChecks() posts to missingPostNotificationsPermission`() = runTest { + fun `showAlarmTimePickerWithChecks() posts to notificationsDisabled before Android 13`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn false } @@ -298,7 +298,7 @@ class FahrplanViewModelTest { runsAtLeastOnAndroidTiramisu = false ) viewModel.showAlarmTimePickerWithChecks() - viewModel.missingPostNotificationsPermission.test { + viewModel.notificationsDisabled.test { assertThat(awaitItem()).isEqualTo(Unit) } } From 502da52b326aeb4372d3a43587d4648010fc2efb Mon Sep 17 00:00:00 2001 From: Tobias Preuss Date: Wed, 24 Jan 2024 23:53:25 +0100 Subject: [PATCH 03/10] Use distinct property name to avoid accidental mix-up. --- .../fahrplan/congress/details/SessionDetailsFragment.kt | 6 +++--- .../tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt index 3bd45fe5e1..5466cddcbd 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt @@ -82,7 +82,7 @@ class SessionDetailsFragment : Fragment() { } } - private lateinit var permissionRequestLauncher: ActivityResultLauncher + private lateinit var postNotificationsPermissionRequestLauncher: ActivityResultLauncher private lateinit var appRepository: AppRepository private lateinit var alarmServices: AlarmServices private lateinit var notificationHelper: NotificationHelper @@ -132,7 +132,7 @@ class SessionDetailsFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - permissionRequestLauncher = registerForActivityResult(RequestPermission()) { isGranted -> + postNotificationsPermissionRequestLauncher = registerForActivityResult(RequestPermission()) { isGranted -> if (isGranted) { viewModel.setAlarm() } else { @@ -212,7 +212,7 @@ class SessionDetailsFragment : Fragment() { } } viewModel.requestPostNotificationsPermission.observe(viewLifecycleOwner) { - permissionRequestLauncher.launch(POST_NOTIFICATIONS) + postNotificationsPermissionRequestLauncher.launch(POST_NOTIFICATIONS) } viewModel.notificationsDisabled.observe(viewLifecycleOwner) { showNotificationsDisabledError() diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt index ab55d7a778..88b6efa0fe 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt @@ -94,7 +94,7 @@ class FahrplanFragment : Fragment(), SessionViewEventsHandler { } - private lateinit var permissionRequestLauncher: ActivityResultLauncher + private lateinit var postNotificationsPermissionRequestLauncher: ActivityResultLauncher private lateinit var inflater: LayoutInflater private lateinit var sessionViewDrawer: SessionViewDrawer private lateinit var errorMessageFactory: ErrorMessage.Factory @@ -140,7 +140,7 @@ class FahrplanFragment : Fragment(), SessionViewEventsHandler { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - permissionRequestLauncher = registerForActivityResult(RequestPermission()) { isGranted -> + postNotificationsPermissionRequestLauncher = registerForActivityResult(RequestPermission()) { isGranted -> if (isGranted) { showAlarmTimePicker() } else { @@ -214,7 +214,7 @@ class FahrplanFragment : Fragment(), SessionViewEventsHandler { scrollTo(sessionId, verticalPosition, roomIndex) } viewModel.requestPostNotificationsPermission.observe(viewLifecycleOwner) { - permissionRequestLauncher.launch(POST_NOTIFICATIONS) + postNotificationsPermissionRequestLauncher.launch(POST_NOTIFICATIONS) } viewModel.notificationsDisabled.observe(viewLifecycleOwner) { showNotificationsDisabledError() From 39c4ce6348e8f76eb9f445ba58bf5dee8de25a41 Mon Sep 17 00:00:00 2001 From: Tobias Preuss Date: Thu, 25 Jan 2024 21:43:24 +0100 Subject: [PATCH 04/10] Revise alarm related names, hence harmonize SessionDetailsViewModel & FahrplanViewModel. --- .../congress/details/SessionDetailsFragment.kt | 6 +++--- .../congress/details/SessionDetailsViewModel.kt | 8 ++++---- .../fahrplan/congress/schedule/FahrplanFragment.kt | 2 +- .../congress/schedule/FahrplanViewModel.kt | 2 +- .../details/SessionDetailsViewModelTest.kt | 14 +++++++------- .../congress/schedule/FahrplanViewModelTest.kt | 12 ++++++------ 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt index 5466cddcbd..dd4dfa9172 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt @@ -108,7 +108,7 @@ class SessionDetailsFragment : Fragment() { R.id.menu_item_add_to_calendar to { addToCalendar() }, R.id.menu_item_flag_as_favorite to { favorSession() }, R.id.menu_item_unflag_as_favorite to { unfavorSession() }, - R.id.menu_item_set_alarm to { setAlarm() }, + R.id.menu_item_set_alarm to { addAlarmWithChecks() }, R.id.menu_item_delete_alarm to { deleteAlarm() }, R.id.menu_item_close_session_details to { closeDetails() }, R.id.menu_item_navigate to { navigateToRoom() }, @@ -134,7 +134,7 @@ class SessionDetailsFragment : Fragment() { postNotificationsPermissionRequestLauncher = registerForActivityResult(RequestPermission()) { isGranted -> if (isGranted) { - viewModel.setAlarm() + viewModel.addAlarmWithChecks() } else { showMissingPostNotificationsPermissionError() } @@ -192,7 +192,7 @@ class SessionDetailsFragment : Fragment() { viewModel.addToCalendar.observe(viewLifecycleOwner) { session -> CalendarSharing(requireContext()).addToCalendar(session) } - viewModel.setAlarm.observe(viewLifecycleOwner) { + viewModel.showAlarmTimePicker.observe(viewLifecycleOwner) { AlarmTimePickerFragment.show(this, SESSION_DETAILS_FRAGMENT_REQUEST_KEY) { requestKey, result -> if (requestKey == SESSION_DETAILS_FRAGMENT_REQUEST_KEY && result.containsKey(AlarmTimePickerFragment.ALARM_TIMES_INDEX_BUNDLE_KEY) diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModel.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModel.kt index ae36f75fa8..35f5c207f7 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModel.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModel.kt @@ -99,8 +99,8 @@ internal class SessionDetailsViewModel( val shareJson = mutableShareJson.receiveAsFlow() private val mutableAddToCalendar = Channel() val addToCalendar = mutableAddToCalendar.receiveAsFlow() - private val mutableSetAlarm = Channel() - val setAlarm = mutableSetAlarm.receiveAsFlow() + private val mutableShowAlarmTimePicker = Channel() + val showAlarmTimePicker = mutableShowAlarmTimePicker.receiveAsFlow() private val mutableNavigateToRoom = Channel() val navigateToRoom = mutableNavigateToRoom.receiveAsFlow() private val mutableCloseDetails = Channel() @@ -206,9 +206,9 @@ internal class SessionDetailsViewModel( } } - fun setAlarm() { + fun addAlarmWithChecks() { if (notificationHelper.notificationsEnabled) { - mutableSetAlarm.sendOneTimeEvent(Unit) + mutableShowAlarmTimePicker.sendOneTimeEvent(Unit) } else { // Check runtime version here because requesting the POST_NOTIFICATION permission // before Android 13 (Tiramisu) has no effect nor error message. diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt index 88b6efa0fe..ffad0cee15 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt @@ -481,7 +481,7 @@ class FahrplanFragment : Fragment(), SessionViewEventsHandler { updateMenuItems() } CONTEXT_MENU_ITEM_ID_SET_ALARM -> { - viewModel.showAlarmTimePickerWithChecks() + viewModel.addAlarmWithChecks() } CONTEXT_MENU_ITEM_ID_DELETE_ALARM -> { viewModel.deleteAlarm(session) diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModel.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModel.kt index da41cabad3..2ff470efd9 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModel.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModel.kt @@ -97,7 +97,7 @@ internal class FahrplanViewModel( updateUncanceledSessions() } - fun showAlarmTimePickerWithChecks() { + fun addAlarmWithChecks() { if (notificationHelper.notificationsEnabled) { mutableShowAlarmTimePicker.sendOneTimeEvent(Unit) } else { diff --git a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModelTest.kt b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModelTest.kt index 2615d7dfb1..d0a00c8dde 100644 --- a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModelTest.kt +++ b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModelTest.kt @@ -305,20 +305,20 @@ class SessionDetailsViewModelTest { } @Test - fun `setAlarm() posts to setAlarm`() = runTest { + fun `addAlarmWithChecks() posts to showAlarmTimePicker`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn true } val repository = createRepository() val viewModel = createViewModel(repository, notificationHelper = notificationHelper) - viewModel.setAlarm() - viewModel.setAlarm.test { + viewModel.addAlarmWithChecks() + viewModel.showAlarmTimePicker.test { assertThat(awaitItem()).isEqualTo(Unit) } } @Test - fun `setAlarm() posts to requestPostNotificationsPermission as of Android 13`() = runTest { + fun `addAlarmWithChecks() posts to requestPostNotificationsPermission as of Android 13`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn false } @@ -328,14 +328,14 @@ class SessionDetailsViewModelTest { notificationHelper = notificationHelper, runsAtLeastOnAndroidTiramisu = true ) - viewModel.setAlarm() + viewModel.addAlarmWithChecks() viewModel.requestPostNotificationsPermission.test { assertThat(awaitItem()).isEqualTo(Unit) } } @Test - fun `setAlarm() posts to notificationsDisabled before Android 13`() = runTest { + fun `addAlarmWithChecks() posts to notificationsDisabled before Android 13`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn false } @@ -345,7 +345,7 @@ class SessionDetailsViewModelTest { notificationHelper = notificationHelper, runsAtLeastOnAndroidTiramisu = false ) - viewModel.setAlarm() + viewModel.addAlarmWithChecks() viewModel.notificationsDisabled.test { assertThat(awaitItem()).isEqualTo(Unit) } diff --git a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModelTest.kt b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModelTest.kt index 27035c7dc0..b119bb54e3 100644 --- a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModelTest.kt +++ b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModelTest.kt @@ -257,20 +257,20 @@ class FahrplanViewModelTest { } @Test - fun `showAlarmTimePickerWithChecks() posts to showAlarmTimePicker`() = runTest { + fun `addAlarmWithChecks() posts to showAlarmTimePicker`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn true } val repository = createRepository() val viewModel = createViewModel(repository, notificationHelper = notificationHelper) - viewModel.showAlarmTimePickerWithChecks() + viewModel.addAlarmWithChecks() viewModel.showAlarmTimePicker.test { assertThat(awaitItem()).isEqualTo(Unit) } } @Test - fun `showAlarmTimePickerWithChecks() posts to requestPostNotificationsPermission as of Android 13`() = runTest { + fun `addAlarmWithChecks() posts to requestPostNotificationsPermission as of Android 13`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn false } @@ -280,14 +280,14 @@ class FahrplanViewModelTest { notificationHelper = notificationHelper, runsAtLeastOnAndroidTiramisu = true ) - viewModel.showAlarmTimePickerWithChecks() + viewModel.addAlarmWithChecks() viewModel.requestPostNotificationsPermission.test { assertThat(awaitItem()).isEqualTo(Unit) } } @Test - fun `showAlarmTimePickerWithChecks() posts to notificationsDisabled before Android 13`() = runTest { + fun `addAlarmWithChecks() posts to notificationsDisabled before Android 13`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn false } @@ -297,7 +297,7 @@ class FahrplanViewModelTest { notificationHelper = notificationHelper, runsAtLeastOnAndroidTiramisu = false ) - viewModel.showAlarmTimePickerWithChecks() + viewModel.addAlarmWithChecks() viewModel.notificationsDisabled.test { assertThat(awaitItem()).isEqualTo(Unit) } From b3134404b74f6abe616b147cec0fdace50b4833d Mon Sep 17 00:00:00 2001 From: Tobias Preuss Date: Thu, 25 Jan 2024 21:50:55 +0100 Subject: [PATCH 05/10] Extract SessionDetailsFragment#showAlarmTimePicker like it is in FahrplanFragment. --- .../details/SessionDetailsFragment.kt | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt index dd4dfa9172..abbabdb0ec 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt @@ -193,14 +193,7 @@ class SessionDetailsFragment : Fragment() { CalendarSharing(requireContext()).addToCalendar(session) } viewModel.showAlarmTimePicker.observe(viewLifecycleOwner) { - AlarmTimePickerFragment.show(this, SESSION_DETAILS_FRAGMENT_REQUEST_KEY) { requestKey, result -> - if (requestKey == SESSION_DETAILS_FRAGMENT_REQUEST_KEY && - result.containsKey(AlarmTimePickerFragment.ALARM_TIMES_INDEX_BUNDLE_KEY) - ) { - val alarmTimesIndex = result.getInt(AlarmTimePickerFragment.ALARM_TIMES_INDEX_BUNDLE_KEY) - viewModel.addAlarm(alarmTimesIndex) - } - } + showAlarmTimePicker() } viewModel.navigateToRoom.observe(viewLifecycleOwner) { uri -> startActivity(Intent(Intent.ACTION_VIEW, uri)) @@ -359,6 +352,17 @@ class SessionDetailsFragment : Fragment() { this.isVisible = true } + private fun showAlarmTimePicker() { + AlarmTimePickerFragment.show(this, SESSION_DETAILS_FRAGMENT_REQUEST_KEY) { requestKey, result -> + if (requestKey == SESSION_DETAILS_FRAGMENT_REQUEST_KEY && + result.containsKey(AlarmTimePickerFragment.ALARM_TIMES_INDEX_BUNDLE_KEY) + ) { + val alarmTimesIndex = result.getInt(AlarmTimePickerFragment.ALARM_TIMES_INDEX_BUNDLE_KEY) + viewModel.addAlarm(alarmTimesIndex) + } + } + } + private fun showMissingPostNotificationsPermissionError() { Toast.makeText(requireContext(), R.string.alarms_disabled_notifications_permission_missing, Toast.LENGTH_LONG).show() } From f4547ca77c6b9d6507d4f6ad46dbf1b7b0ff7301 Mon Sep 17 00:00:00 2001 From: Tobias Preuss Date: Thu, 25 Jan 2024 22:18:26 +0100 Subject: [PATCH 06/10] Avoid bypassing the view model. + This change does not affect functionality nor tests. --- .../tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt index ffad0cee15..9d3d04e0a1 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt @@ -142,7 +142,7 @@ class FahrplanFragment : Fragment(), SessionViewEventsHandler { postNotificationsPermissionRequestLauncher = registerForActivityResult(RequestPermission()) { isGranted -> if (isGranted) { - showAlarmTimePicker() + viewModel.addAlarmWithChecks() } else { showMissingPostNotificationsPermissionError() } From bb1bad86db3e0ddbd3e9c6d0a58f3edbbcc12180 Mon Sep 17 00:00:00 2001 From: Tobias Preuss Date: Fri, 26 Jan 2024 17:56:24 +0100 Subject: [PATCH 07/10] Verify that NotificationHelper is wired correctly. --- .../fahrplan/congress/details/SessionDetailsViewModelTest.kt | 2 ++ .../fahrplan/congress/schedule/FahrplanViewModelTest.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModelTest.kt b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModelTest.kt index d0a00c8dde..00bc284846 100644 --- a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModelTest.kt +++ b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModelTest.kt @@ -315,6 +315,7 @@ class SessionDetailsViewModelTest { viewModel.showAlarmTimePicker.test { assertThat(awaitItem()).isEqualTo(Unit) } + verifyInvokedOnce(notificationHelper).notificationsEnabled } @Test @@ -332,6 +333,7 @@ class SessionDetailsViewModelTest { viewModel.requestPostNotificationsPermission.test { assertThat(awaitItem()).isEqualTo(Unit) } + verifyInvokedOnce(notificationHelper).notificationsEnabled } @Test diff --git a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModelTest.kt b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModelTest.kt index b119bb54e3..62e969f3af 100644 --- a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModelTest.kt +++ b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModelTest.kt @@ -267,6 +267,7 @@ class FahrplanViewModelTest { viewModel.showAlarmTimePicker.test { assertThat(awaitItem()).isEqualTo(Unit) } + verifyInvokedOnce(notificationHelper).notificationsEnabled } @Test @@ -284,6 +285,7 @@ class FahrplanViewModelTest { viewModel.requestPostNotificationsPermission.test { assertThat(awaitItem()).isEqualTo(Unit) } + verifyInvokedOnce(notificationHelper).notificationsEnabled } @Test From 397c4f420cf1fc1f97e48ea394f1fcd4f05e74a7 Mon Sep 17 00:00:00 2001 From: Tobias Preuss Date: Thu, 25 Jan 2024 22:03:54 +0100 Subject: [PATCH 08/10] Extract common code into SessionAlarmViewModelDelegate. --- .../alarms/SessionAlarmViewModelDelegate.kt | 59 ++++++++++++ .../details/SessionDetailsViewModel.kt | 46 ++++----- .../congress/schedule/FahrplanViewModel.kt | 48 +++++----- .../SessionAlarmViewModelDelegateTest.kt | 94 +++++++++++++++++++ 4 files changed, 201 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegate.kt create mode 100644 app/src/test/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegateTest.kt diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegate.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegate.kt new file mode 100644 index 0000000000..b1475013b9 --- /dev/null +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegate.kt @@ -0,0 +1,59 @@ +package nerd.tuxmobil.fahrplan.congress.alarms + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import nerd.tuxmobil.fahrplan.congress.models.Session +import nerd.tuxmobil.fahrplan.congress.notifications.NotificationHelper + +/** + * Wraps all concerns related to adding and deleting sessions alarms + * and the related permission checks. This handling is centralized here + * to avoid code duplication in the individual view models. + */ +internal class SessionAlarmViewModelDelegate( + private val viewModelScope: CoroutineScope, + private val notificationHelper: NotificationHelper, + private val alarmServices: AlarmServices, + private val runsAtLeastOnAndroidTiramisu: Boolean, +) { + + private val mutableShowAlarmTimePicker = Channel() + val showAlarmTimePicker = mutableShowAlarmTimePicker + .receiveAsFlow() + + private val mutableRequestPostNotificationsPermission = Channel() + val requestPostNotificationsPermission = mutableRequestPostNotificationsPermission + .receiveAsFlow() + + private val mutableNotificationsDisabled = Channel() + val notificationsDisabled = mutableNotificationsDisabled + .receiveAsFlow() + + fun addAlarmWithChecks() { + if (notificationHelper.notificationsEnabled) { + mutableShowAlarmTimePicker.sendOneTimeEvent(Unit) + } else { + // Check runtime version here because requesting the POST_NOTIFICATION permission + // before Android 13 (Tiramisu) has no effect nor error message. + when (runsAtLeastOnAndroidTiramisu) { + true -> mutableRequestPostNotificationsPermission.sendOneTimeEvent(Unit) + false -> mutableNotificationsDisabled.sendOneTimeEvent(Unit) + } + } + } + + fun addAlarm(session: Session, alarmTimesIndex: Int) = + alarmServices.addSessionAlarm(session, alarmTimesIndex) + + fun deleteAlarm(session: Session) = alarmServices.deleteSessionAlarm(session) + + private fun SendChannel.sendOneTimeEvent(event: E) { + viewModelScope.launch { + send(event) + } + } + +} diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModel.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModel.kt index 35f5c207f7..680ba3d5ef 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModel.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import nerd.tuxmobil.fahrplan.congress.alarms.AlarmServices +import nerd.tuxmobil.fahrplan.congress.alarms.SessionAlarmViewModelDelegate import nerd.tuxmobil.fahrplan.congress.dataconverters.toRoom import nerd.tuxmobil.fahrplan.congress.models.Session import nerd.tuxmobil.fahrplan.congress.navigation.IndoorNavigation @@ -34,8 +35,8 @@ internal class SessionDetailsViewModel( private val repository: AppRepository, private val executionContext: ExecutionContext, - private val alarmServices: AlarmServices, - private val notificationHelper: NotificationHelper, + alarmServices: AlarmServices, + notificationHelper: NotificationHelper, private val sessionFormatter: SessionFormatter, private val simpleSessionFormat: SimpleSessionFormat, private val jsonSessionFormat: JsonSessionFormat, @@ -46,7 +47,7 @@ internal class SessionDetailsViewModel( private val formattingDelegate: FormattingDelegate = DateFormattingDelegate(), private val defaultEngelsystemRoomName: String, private val customEngelsystemRoomName: String, - private val runsAtLeastOnAndroidTiramisu: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + runsAtLeastOnAndroidTiramisu: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ) : ViewModel() { @@ -74,6 +75,14 @@ internal class SessionDetailsViewModel( } + private var sessionAlarmViewModelDelegate: SessionAlarmViewModelDelegate = + SessionAlarmViewModelDelegate( + viewModelScope, + notificationHelper, + alarmServices, + runsAtLeastOnAndroidTiramisu, + ) + val abstractFont = Font.Roboto.Bold val descriptionFont = Font.Roboto.Regular val linksFont = Font.Roboto.Regular @@ -99,17 +108,19 @@ internal class SessionDetailsViewModel( val shareJson = mutableShareJson.receiveAsFlow() private val mutableAddToCalendar = Channel() val addToCalendar = mutableAddToCalendar.receiveAsFlow() - private val mutableShowAlarmTimePicker = Channel() - val showAlarmTimePicker = mutableShowAlarmTimePicker.receiveAsFlow() private val mutableNavigateToRoom = Channel() val navigateToRoom = mutableNavigateToRoom.receiveAsFlow() private val mutableCloseDetails = Channel() val closeDetails = mutableCloseDetails.receiveAsFlow() - private val mutableRequestPostNotificationsPermission = Channel() - val requestPostNotificationsPermission = - mutableRequestPostNotificationsPermission.receiveAsFlow() - private val mutableNotificationsDisabled = Channel() - val notificationsDisabled = mutableNotificationsDisabled.receiveAsFlow() + + val requestPostNotificationsPermission = sessionAlarmViewModelDelegate + .requestPostNotificationsPermission + + val notificationsDisabled = sessionAlarmViewModelDelegate + .notificationsDisabled + + val showAlarmTimePicker = sessionAlarmViewModelDelegate + .showAlarmTimePicker private fun SelectedSessionParameter.customizeEngelsystemRoomName() = copy( roomName = if (roomName == defaultEngelsystemRoomName) customEngelsystemRoomName else roomName @@ -207,27 +218,18 @@ internal class SessionDetailsViewModel( } fun addAlarmWithChecks() { - if (notificationHelper.notificationsEnabled) { - mutableShowAlarmTimePicker.sendOneTimeEvent(Unit) - } else { - // Check runtime version here because requesting the POST_NOTIFICATION permission - // before Android 13 (Tiramisu) has no effect nor error message. - when (runsAtLeastOnAndroidTiramisu) { - true -> mutableRequestPostNotificationsPermission.sendOneTimeEvent(Unit) - false -> mutableNotificationsDisabled.sendOneTimeEvent(Unit) - } - } + sessionAlarmViewModelDelegate.addAlarmWithChecks() } fun addAlarm(alarmTimesIndex: Int) { loadSelectedSession { session -> - alarmServices.addSessionAlarm(session, alarmTimesIndex) + sessionAlarmViewModelDelegate.addAlarm(session, alarmTimesIndex) } } fun deleteAlarm() { loadSelectedSession { session -> - alarmServices.deleteSessionAlarm(session) + sessionAlarmViewModelDelegate.deleteAlarm(session) } } diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModel.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModel.kt index 2ff470efd9..89fa90ca98 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModel.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import nerd.tuxmobil.fahrplan.congress.alarms.AlarmServices +import nerd.tuxmobil.fahrplan.congress.alarms.SessionAlarmViewModelDelegate import nerd.tuxmobil.fahrplan.congress.models.Alarm import nerd.tuxmobil.fahrplan.congress.models.ScheduleData import nerd.tuxmobil.fahrplan.congress.models.Session @@ -36,15 +37,15 @@ internal class FahrplanViewModel( private val repository: AppRepository, private val executionContext: ExecutionContext, private val logging: Logging, - private val alarmServices: AlarmServices, - private val notificationHelper: NotificationHelper, + alarmServices: AlarmServices, + notificationHelper: NotificationHelper, private val navigationMenuEntriesGenerator: NavigationMenuEntriesGenerator, private val simpleSessionFormat: SimpleSessionFormat, private val jsonSessionFormat: JsonSessionFormat, private val scrollAmountCalculator: ScrollAmountCalculator, private val defaultEngelsystemRoomName: String, private val customEngelsystemRoomName: String, - private val runsAtLeastOnAndroidTiramisu: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + runsAtLeastOnAndroidTiramisu: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ) : ViewModel() { @@ -52,6 +53,14 @@ internal class FahrplanViewModel( const val LOG_TAG = "FahrplanViewModel" } + private var sessionAlarmViewModelDelegate: SessionAlarmViewModelDelegate = + SessionAlarmViewModelDelegate( + viewModelScope, + notificationHelper, + alarmServices, + runsAtLeastOnAndroidTiramisu, + ) + val fahrplanParameter = combine( repository.uncanceledSessionsForDayIndex.filter { it.allSessions.isNotEmpty() }, repository.sessionsWithoutShifts.filterNotNull(), @@ -82,14 +91,14 @@ internal class FahrplanViewModel( private val mutableScrollToSessionParameter = Channel() val scrollToSessionParameter = mutableScrollToSessionParameter.receiveAsFlow() - private val mutableRequestPostNotificationsPermission = Channel() - val requestPostNotificationsPermission = mutableRequestPostNotificationsPermission.receiveAsFlow() + val requestPostNotificationsPermission = sessionAlarmViewModelDelegate + .requestPostNotificationsPermission - private val mutableNotificationsDisabled = Channel() - val notificationsDisabled = mutableNotificationsDisabled.receiveAsFlow() + val notificationsDisabled = sessionAlarmViewModelDelegate + .notificationsDisabled - private val mutableShowAlarmTimePicker = Channel() - val showAlarmTimePicker = mutableShowAlarmTimePicker.receiveAsFlow() + val showAlarmTimePicker = sessionAlarmViewModelDelegate + .showAlarmTimePicker var preserveVerticalScrollPosition: Boolean = false @@ -97,19 +106,6 @@ internal class FahrplanViewModel( updateUncanceledSessions() } - fun addAlarmWithChecks() { - if (notificationHelper.notificationsEnabled) { - mutableShowAlarmTimePicker.sendOneTimeEvent(Unit) - } else { - // Check runtime version here because requesting the POST_NOTIFICATION permission - // before Android 13 (Tiramisu) has no effect nor error message. - when (runsAtLeastOnAndroidTiramisu) { - true -> mutableRequestPostNotificationsPermission.sendOneTimeEvent(Unit) - false -> mutableNotificationsDisabled.sendOneTimeEvent(Unit) - } - } - } - private fun updateUncanceledSessions() { launch { repository.uncanceledSessionsForDayIndex.collect { scheduleData -> @@ -250,15 +246,19 @@ internal class FahrplanViewModel( } } + fun addAlarmWithChecks() { + sessionAlarmViewModelDelegate.addAlarmWithChecks() + } + fun addAlarm(session: Session, alarmTimesIndex: Int) { launch { - alarmServices.addSessionAlarm(session, alarmTimesIndex) + sessionAlarmViewModelDelegate.addAlarm(session, alarmTimesIndex) } } fun deleteAlarm(session: Session) { launch { - alarmServices.deleteSessionAlarm(session) + sessionAlarmViewModelDelegate.deleteAlarm(session) } } diff --git a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegateTest.kt b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegateTest.kt new file mode 100644 index 0000000000..fdfe1e9faf --- /dev/null +++ b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegateTest.kt @@ -0,0 +1,94 @@ +package nerd.tuxmobil.fahrplan.congress.alarms + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import info.metadude.android.eventfahrplan.commons.testing.MainDispatcherTestRule +import info.metadude.android.eventfahrplan.commons.testing.verifyInvokedOnce +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import nerd.tuxmobil.fahrplan.congress.models.Session +import nerd.tuxmobil.fahrplan.congress.notifications.NotificationHelper +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class SessionAlarmViewModelDelegateTest { + + private companion object { + val SESSION = Session("session-23") + } + + @get:Rule + val mainDispatcherTestRule = MainDispatcherTestRule() + + @Test + fun `addAlarm() invokes addSessionAlarm() function`() = runTest { + val alarmServices = mock() + val delegate = createDelegate(alarmServices = alarmServices) + delegate.addAlarm(SESSION, alarmTimesIndex = 0) + verifyInvokedOnce(alarmServices).addSessionAlarm(SESSION, alarmTimesIndex = 0) + } + + @Test + fun `deleteAlarm() invokes deleteSessionAlarm() function`() = runTest { + val alarmServices = mock() + val delegate = createDelegate(alarmServices = alarmServices) + delegate.deleteAlarm(SESSION) + verifyInvokedOnce(alarmServices).deleteSessionAlarm(SESSION) + } + + @Test + fun `addAlarmWithChecks() posts to showAlarmTimePicker`() = runTest { + val notificationHelper = mock { + on { notificationsEnabled } doReturn true + } + val delegate = createDelegate(notificationHelper) + delegate.addAlarmWithChecks() + delegate.showAlarmTimePicker.test { + assertThat(awaitItem()).isEqualTo(Unit) + } + } + + @Test + fun `addAlarmWithChecks() posts to requestPostNotificationsPermission as of Android 13`() = runTest { + val notificationHelper = mock { + on { notificationsEnabled } doReturn false + } + val delegate = createDelegate( + notificationHelper = notificationHelper, + runsAtLeastOnAndroidTiramisu = true, + ) + delegate.addAlarmWithChecks() + delegate.requestPostNotificationsPermission.test { + assertThat(awaitItem()).isEqualTo(Unit) + } + } + + @Test + fun `addAlarmWithChecks() posts to notificationsDisabled before Android 13`() = runTest { + val notificationHelper = mock { + on { notificationsEnabled } doReturn false + } + val delegate = createDelegate( + notificationHelper = notificationHelper, + runsAtLeastOnAndroidTiramisu = false, + ) + delegate.addAlarmWithChecks() + delegate.notificationsDisabled.test { + assertThat(awaitItem()).isEqualTo(Unit) + } + } + + private fun TestScope.createDelegate( + notificationHelper: NotificationHelper = mock(), + alarmServices: AlarmServices = mock(), + runsAtLeastOnAndroidTiramisu: Boolean = true, + ) = SessionAlarmViewModelDelegate( + viewModelScope = this, + notificationHelper = notificationHelper, + alarmServices = alarmServices, + runsAtLeastOnAndroidTiramisu = runsAtLeastOnAndroidTiramisu, + ) + +} From 936f1e81415719fc34f60d24cafb3b15d8a9b697 Mon Sep 17 00:00:00 2001 From: Tobias Preuss Date: Fri, 26 Jan 2024 11:57:20 +0100 Subject: [PATCH 09/10] Support scheduling exact alarms for sessions. + This does not affect alarms scheduled for loading schedule updates. + Docs: https://developer.android.com/about/versions/12/behavior-changes-12#exact-alarm-permission https://developer.android.com/about/versions/13/features#use-exact-alarm-permission https://developer.android.com/about/versions/14/changes/schedule-exact-alarms https://developer.android.com/reference/android/Manifest.permission#SCHEDULE_EXACT_ALARM https://developer.android.com/reference/androidx/core/app/AlarmManagerCompat#setExactAndAllowWhileIdle(android.app.AlarmManager,int,long,android.app.PendingIntent) https://developer.android.com/reference/android/app/AlarmManager#canScheduleExactAlarms() Resolves #612 --- app/src/main/AndroidManifest.xml | 1 + .../fahrplan/congress/alarms/AlarmServices.kt | 32 +++++++---- .../alarms/SessionAlarmViewModelDelegate.kt | 12 +++- .../details/SessionDetailsFragment.kt | 32 +++++++++++ .../details/SessionDetailsViewModel.kt | 7 +++ .../congress/schedule/FahrplanFragment.kt | 33 +++++++++++ .../congress/schedule/FahrplanViewModel.kt | 7 +++ app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../congress/alarms/AlarmServicesTest.kt | 55 ++++++++++++++++++- .../SessionAlarmViewModelDelegateTest.kt | 28 +++++++++- .../details/SessionDetailsViewModelTest.kt | 46 +++++++++++++++- .../schedule/FahrplanViewModelTest.kt | 47 +++++++++++++++- 13 files changed, 282 insertions(+), 20 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0a06e4d66f..542253a131 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + , private val logging: Logging, private val pendingIntentDelegate: PendingIntentDelegate = PendingIntentProvider, - private val formattingDelegate: FormattingDelegate = DateFormatterDelegate + private val formattingDelegate: FormattingDelegate = DateFormatterDelegate, + private val runsAtLeastOnAndroidSnowCone: Boolean = SDK_INT >= S, ) { @@ -143,6 +147,17 @@ class AlarmServices @VisibleForTesting constructor( session.hasAlarm = false } + /** + * Returns `true` if the app is allowed to schedule exact alarms. Otherwise `false`. + * Automatically allowed before Android 12, SnowCone (API level 31) but might be withdrawn + * by the user via the system settings. + * + * See: [AlarmManager.canScheduleExactAlarms]. + */ + val canScheduleExactAlarms: Boolean + @SuppressLint("NewApi") + get() = if (runsAtLeastOnAndroidSnowCone) alarmManager.canScheduleExactAlarms() else true + /** * Schedules the given [alarm] via the [AlarmManager]. * Existing alarms for the associated session are discarded if configured via [discardExisting]. @@ -161,15 +176,12 @@ class AlarmServices @VisibleForTesting constructor( if (discardExisting) { alarmManager.cancel(pendingIntent) } - // Alarms scheduled here are treated as inexact as of targeting Android 4.4 (API level 19). - // See https://developer.android.com/training/scheduling/alarms - // and https://developer.android.com/reference/android/os/Build.VERSION_CODES#KITKAT - // SCHEDULE_EXACT_ALARM permission is needed when switching to exact alarms as of targeting Android 12 (API level 31). - // See https://developer.android.com/about/versions/12/behavior-changes-12#exact-alarm-permission - // USE_EXACT_ALARM permission is needed when switching to exact alarms as of targeting Android 13 (API level 33). - // See https://developer.android.com/about/versions/13/features#use-exact-alarm-permission - // and https://support.google.com/googleplay/android-developer/answer/12253906#exact_alarm_preview - alarmManager.set(AlarmManager.RTC_WAKEUP, alarm.startTime, pendingIntent) + AlarmManagerCompat.setExactAndAllowWhileIdle( + alarmManager, + AlarmManager.RTC_WAKEUP, + alarm.startTime, + pendingIntent + ) } /** diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegate.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegate.kt index b1475013b9..2f50e78900 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegate.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegate.kt @@ -32,9 +32,19 @@ internal class SessionAlarmViewModelDelegate( val notificationsDisabled = mutableNotificationsDisabled .receiveAsFlow() + private val mutableRequestScheduleExactAlarmsPermission = Channel() + val requestScheduleExactAlarmsPermission = mutableRequestScheduleExactAlarmsPermission + .receiveAsFlow() + + fun canAddAlarms() = alarmServices.canScheduleExactAlarms + fun addAlarmWithChecks() { if (notificationHelper.notificationsEnabled) { - mutableShowAlarmTimePicker.sendOneTimeEvent(Unit) + if (alarmServices.canScheduleExactAlarms) { + mutableShowAlarmTimePicker.sendOneTimeEvent(Unit) + } else { + mutableRequestScheduleExactAlarmsPermission.sendOneTimeEvent(Unit) + } } else { // Check runtime version here because requesting the POST_NOTIFICATION permission // before Android 13 (Tiramisu) has no effect nor error message. diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt index abbabdb0ec..16a44502f9 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt @@ -3,10 +3,12 @@ package nerd.tuxmobil.fahrplan.congress.details import android.Manifest.permission.POST_NOTIFICATIONS import android.annotation.SuppressLint import android.app.Activity +import android.app.Activity.RESULT_OK import android.content.Context import android.content.Intent import android.graphics.Typeface import android.os.Bundle +import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -17,11 +19,13 @@ import android.widget.TextView import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.annotation.CallSuper import androidx.annotation.IdRes import androidx.annotation.LayoutRes import androidx.annotation.MainThread import androidx.core.content.ContextCompat +import androidx.core.net.toUri import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager @@ -83,6 +87,7 @@ class SessionDetailsFragment : Fragment() { } private lateinit var postNotificationsPermissionRequestLauncher: ActivityResultLauncher + private lateinit var scheduleExactAlarmsPermissionRequestLauncher: ActivityResultLauncher private lateinit var appRepository: AppRepository private lateinit var alarmServices: AlarmServices private lateinit var notificationHelper: NotificationHelper @@ -140,6 +145,24 @@ class SessionDetailsFragment : Fragment() { } } + scheduleExactAlarmsPermissionRequestLauncher = + registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + // User granted the permission earlier. + viewModel.addAlarmWithChecks() + } else { + // User granted the permission for the first time. + // Screen is resumed with RESULT_CANCELED, no indication + // of whether the permission was granted or not. + // Hence the following ugly view model bypass. + if (viewModel.canAddAlarms()) { + viewModel.addAlarmWithChecks() + } else { + showMissingScheduleExactAlarmsPermissionError() + } + } + } + setHasOptionsMenu(true) } @@ -210,6 +233,11 @@ class SessionDetailsFragment : Fragment() { viewModel.notificationsDisabled.observe(viewLifecycleOwner) { showNotificationsDisabledError() } + viewModel.requestScheduleExactAlarmsPermission.observe(viewLifecycleOwner) { + val intent = Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM) + .setData("package:${BuildConfig.APPLICATION_ID}".toUri()) + scheduleExactAlarmsPermissionRequestLauncher.launch(intent) + } } private fun updateView() { @@ -371,6 +399,10 @@ class SessionDetailsFragment : Fragment() { Toast.makeText(requireContext(), R.string.alarms_disabled_notifications_are_disabled, Toast.LENGTH_LONG).show() } + private fun showMissingScheduleExactAlarmsPermissionError() { + Toast.makeText(requireContext(), R.string.alarms_disabled_schedule_exact_alarm_permission_missing, Toast.LENGTH_LONG).show() + } + private fun updateOptionsMenu() { requireActivity().invalidateOptionsMenu() } diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModel.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModel.kt index 680ba3d5ef..a96598bc49 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModel.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModel.kt @@ -119,6 +119,9 @@ internal class SessionDetailsViewModel( val notificationsDisabled = sessionAlarmViewModelDelegate .notificationsDisabled + val requestScheduleExactAlarmsPermission = sessionAlarmViewModelDelegate + .requestScheduleExactAlarmsPermission + val showAlarmTimePicker = sessionAlarmViewModelDelegate .showAlarmTimePicker @@ -217,6 +220,10 @@ internal class SessionDetailsViewModel( } } + fun canAddAlarms(): Boolean { + return sessionAlarmViewModelDelegate.canAddAlarms() + } + fun addAlarmWithChecks() { sessionAlarmViewModelDelegate.addAlarmWithChecks() } diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt index 9d3d04e0a1..3f0cc5d5ed 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanFragment.kt @@ -2,10 +2,13 @@ package nerd.tuxmobil.fahrplan.congress.schedule import android.Manifest.permission.POST_NOTIFICATIONS import android.annotation.SuppressLint +import android.app.Activity.RESULT_OK import android.content.Context +import android.content.Intent import android.graphics.Typeface import android.os.Build import android.os.Bundle +import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM import android.text.TextUtils.TruncateAt import android.view.ContextMenu import android.view.ContextMenu.ContextMenuInfo @@ -26,10 +29,12 @@ import android.widget.TextView import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.appcompat.app.ActionBar.NAVIGATION_MODE_LIST import androidx.appcompat.app.ActionBar.OnNavigationListener import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat +import androidx.core.net.toUri import androidx.core.view.updatePadding import androidx.core.widget.NestedScrollView import androidx.core.widget.NestedScrollView.OnScrollChangeListener @@ -95,6 +100,7 @@ class FahrplanFragment : Fragment(), SessionViewEventsHandler { } private lateinit var postNotificationsPermissionRequestLauncher: ActivityResultLauncher + private lateinit var scheduleExactAlarmsPermissionRequestLauncher: ActivityResultLauncher private lateinit var inflater: LayoutInflater private lateinit var sessionViewDrawer: SessionViewDrawer private lateinit var errorMessageFactory: ErrorMessage.Factory @@ -148,6 +154,24 @@ class FahrplanFragment : Fragment(), SessionViewEventsHandler { } } + scheduleExactAlarmsPermissionRequestLauncher = + registerForActivityResult(StartActivityForResult()) { result -> + // User granted the permission earlier. + if (result.resultCode == RESULT_OK) { + viewModel.addAlarmWithChecks() + } else { + // User granted the permission for the first time. + // Screen is resumed with RESULT_CANCELED, no indication + // of whether the permission was granted or not. + // Hence the following ugly view model bypass. + if (viewModel.canAddAlarms()) { + viewModel.addAlarmWithChecks() + } else { + showMissingScheduleExactAlarmsPermissionError() + } + } + } + setHasOptionsMenu(true) val context = requireContext() roomTitleTypeFace = TypefaceFactory.getNewInstance(context).getTypeface(Font.Roboto.Light) @@ -219,6 +243,11 @@ class FahrplanFragment : Fragment(), SessionViewEventsHandler { viewModel.notificationsDisabled.observe(viewLifecycleOwner) { showNotificationsDisabledError() } + viewModel.requestScheduleExactAlarmsPermission.observe(viewLifecycleOwner) { + val intent = Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM) + .setData("package:${BuildConfig.APPLICATION_ID}".toUri()) + scheduleExactAlarmsPermissionRequestLauncher.launch(intent) + } viewModel.showAlarmTimePicker.observe(viewLifecycleOwner) { showAlarmTimePicker() } @@ -557,6 +586,10 @@ class FahrplanFragment : Fragment(), SessionViewEventsHandler { Toast.makeText(requireContext(), R.string.alarms_disabled_notifications_are_disabled, Toast.LENGTH_LONG).show() } + private fun showMissingScheduleExactAlarmsPermissionError() { + Toast.makeText(requireContext(), R.string.alarms_disabled_schedule_exact_alarm_permission_missing, Toast.LENGTH_LONG).show() + } + private inner class OnDaySelectedListener(private val numDays: Int) : OnNavigationListener { private var isSynthetic = true diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModel.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModel.kt index 89fa90ca98..aab2ed6d2f 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModel.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModel.kt @@ -97,6 +97,9 @@ internal class FahrplanViewModel( val notificationsDisabled = sessionAlarmViewModelDelegate .notificationsDisabled + val requestScheduleExactAlarmsPermission = sessionAlarmViewModelDelegate + .requestScheduleExactAlarmsPermission + val showAlarmTimePicker = sessionAlarmViewModelDelegate .showAlarmTimePicker @@ -246,6 +249,10 @@ internal class FahrplanViewModel( } } + fun canAddAlarms(): Boolean { + return sessionAlarmViewModelDelegate.canAddAlarms() + } + fun addAlarmWithChecks() { sessionAlarmViewModelDelegate.addAlarmWithChecks() } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1a26766714..58c8570429 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -204,6 +204,7 @@ Alarme sind deaktiviert. Bitte erteile die \"Benachrichtigungen\" Berechtigung. Alarme sind deaktiviert. Bitte aktiviere Benachrichtigungen. + Alarme sind deaktiviert. Bitte \"Erlaube, Wecker & Erinnerungen einzurichten\". %d min. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ff4a03c223..ce6ae0be59 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -235,6 +235,7 @@ Alarms are disabled. Please grant the \"Notifications\" permission. Alarms are disabled. Please enable notifications. + Alarms are disabled. Please \"Allow setting alarms & reminders\". %d min. diff --git a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/alarms/AlarmServicesTest.kt b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/alarms/AlarmServicesTest.kt index 1a9dc57b44..3425973f02 100644 --- a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/alarms/AlarmServicesTest.kt +++ b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/alarms/AlarmServicesTest.kt @@ -124,6 +124,54 @@ class AlarmServicesTest { assertThat(session.hasAlarm).isFalse() } + @Test + fun `canScheduleExactAlarms returns true before SnowCone 1`() { + val alarmManager = mock { + on { canScheduleExactAlarms() } doReturn false // not relevant + } + val alarmServices = createAlarmServices( + alarmManager = alarmManager, + runsAtLeastOnAndroidSnowCone = false, + ) + assertThat(alarmServices.canScheduleExactAlarms).isTrue() + } + + @Test + fun `canScheduleExactAlarms returns true before SnowCone 2`() { + val alarmManager = mock { + on { canScheduleExactAlarms() } doReturn true // not relevant + } + val alarmServices = createAlarmServices( + alarmManager = alarmManager, + runsAtLeastOnAndroidSnowCone = false, + ) + assertThat(alarmServices.canScheduleExactAlarms).isTrue() + } + + @Test + fun `canScheduleExactAlarms returns false as of SnowCone and alarmManager returns false`() { + val alarmManager = mock { + on { canScheduleExactAlarms() } doReturn false + } + val alarmServices = createAlarmServices( + alarmManager = alarmManager, + runsAtLeastOnAndroidSnowCone = true, + ) + assertThat(alarmServices.canScheduleExactAlarms).isFalse() + } + + @Test + fun `canScheduleExactAlarms returns true as of SnowCone and alarmManager returns true`() { + val alarmManager = mock { + on { canScheduleExactAlarms() } doReturn true + } + val alarmServices = createAlarmServices( + alarmManager = alarmManager, + runsAtLeastOnAndroidSnowCone = true, + ) + assertThat(alarmServices.canScheduleExactAlarms).isTrue() + } + @Test fun `scheduleSessionAlarm invokes cancel then set when discardExisting is true`() { val pendingIntent = mock() @@ -201,7 +249,9 @@ class AlarmServicesTest { private fun createAlarmServices( pendingIntentDelegate: PendingIntentDelegate = mock(), - formattingDelegate: FormattingDelegate = mock() + formattingDelegate: FormattingDelegate = mock(), + alarmManager: AlarmManager = this.alarmManager, + runsAtLeastOnAndroidSnowCone: Boolean = true, ) = AlarmServices( context = mockContext, repository = repository, @@ -209,7 +259,8 @@ class AlarmServicesTest { alarmTimeValues = alarmTimesValues, logging = NoLogging, pendingIntentDelegate = pendingIntentDelegate, - formattingDelegate = formattingDelegate + formattingDelegate = formattingDelegate, + runsAtLeastOnAndroidSnowCone = runsAtLeastOnAndroidSnowCone, ) } diff --git a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegateTest.kt b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegateTest.kt index fdfe1e9faf..0776e21bfe 100644 --- a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegateTest.kt +++ b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegateTest.kt @@ -38,18 +38,44 @@ class SessionAlarmViewModelDelegateTest { verifyInvokedOnce(alarmServices).deleteSessionAlarm(SESSION) } + @Test + fun `canAddAlarms() invokes canScheduleExactAlarms property`() = runTest { + val alarmServices = mock() + val delegate = createDelegate(alarmServices = alarmServices) + delegate.canAddAlarms() + verifyInvokedOnce(alarmServices).canScheduleExactAlarms + } + @Test fun `addAlarmWithChecks() posts to showAlarmTimePicker`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn true } - val delegate = createDelegate(notificationHelper) + val alarmServices = mock { + on { canScheduleExactAlarms } doReturn true + } + val delegate = createDelegate(notificationHelper, alarmServices) delegate.addAlarmWithChecks() delegate.showAlarmTimePicker.test { assertThat(awaitItem()).isEqualTo(Unit) } } + @Test + fun `addAlarmWithChecks() posts to requestScheduleExactAlarmsPermission`() = runTest { + val notificationHelper = mock { + on { notificationsEnabled } doReturn true + } + val alarmServices = mock { + on { canScheduleExactAlarms } doReturn false + } + val delegate = createDelegate(notificationHelper, alarmServices) + delegate.addAlarmWithChecks() + delegate.requestScheduleExactAlarmsPermission.test { + assertThat(awaitItem()).isEqualTo(Unit) + } + } + @Test fun `addAlarmWithChecks() posts to requestPostNotificationsPermission as of Android 13`() = runTest { val notificationHelper = mock { diff --git a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModelTest.kt b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModelTest.kt index 00bc284846..6e2bd1ef61 100644 --- a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModelTest.kt +++ b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsViewModelTest.kt @@ -304,18 +304,58 @@ class SessionDetailsViewModelTest { verifyInvokedOnce(repository).updateHighlight(expectedSession) } + @Test + fun `canAddAlarms invokes canScheduleExactAlarms property`() { + val repository = createRepository() + val alarmServices = mock() + val viewModel = createViewModel(repository = repository, alarmServices = alarmServices) + viewModel.canAddAlarms() + verifyInvokedOnce(alarmServices).canScheduleExactAlarms + } + @Test fun `addAlarmWithChecks() posts to showAlarmTimePicker`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn true } + val alarmServices = mock { + on { canScheduleExactAlarms } doReturn true + } val repository = createRepository() - val viewModel = createViewModel(repository, notificationHelper = notificationHelper) + val viewModel = createViewModel( + repository = repository, + notificationHelper = notificationHelper, + alarmServices = alarmServices, + ) viewModel.addAlarmWithChecks() viewModel.showAlarmTimePicker.test { assertThat(awaitItem()).isEqualTo(Unit) } verifyInvokedOnce(notificationHelper).notificationsEnabled + verifyInvokedOnce(alarmServices).canScheduleExactAlarms + } + + @Test + fun `addAlarmWithChecks() posts to requestScheduleExactAlarmsPermission`() = runTest { + val notificationHelper = mock { + on { notificationsEnabled } doReturn true + } + val alarmServices = mock { + on { canScheduleExactAlarms } doReturn false + } + val repository = createRepository() + val viewModel = createViewModel( + repository = repository, + notificationHelper = notificationHelper, + alarmServices = alarmServices, + runsAtLeastOnAndroidTiramisu = true, // not relevant + ) + viewModel.addAlarmWithChecks() + viewModel.requestScheduleExactAlarmsPermission.test { + assertThat(awaitItem()).isEqualTo(Unit) + } + verifyInvokedOnce(notificationHelper).notificationsEnabled + verifyInvokedOnce(alarmServices).canScheduleExactAlarms } @Test @@ -327,7 +367,7 @@ class SessionDetailsViewModelTest { val viewModel = createViewModel( repository = repository, notificationHelper = notificationHelper, - runsAtLeastOnAndroidTiramisu = true + runsAtLeastOnAndroidTiramisu = true, ) viewModel.addAlarmWithChecks() viewModel.requestPostNotificationsPermission.test { @@ -345,7 +385,7 @@ class SessionDetailsViewModelTest { val viewModel = createViewModel( repository = repository, notificationHelper = notificationHelper, - runsAtLeastOnAndroidTiramisu = false + runsAtLeastOnAndroidTiramisu = false, ) viewModel.addAlarmWithChecks() viewModel.notificationsDisabled.test { diff --git a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModelTest.kt b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModelTest.kt index 62e969f3af..001da5194a 100644 --- a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModelTest.kt +++ b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/FahrplanViewModelTest.kt @@ -256,18 +256,59 @@ class FahrplanViewModelTest { verifyInvokedOnce(repository).updateHighlight(Session("session-52")) } + @Test + fun `canAddAlarms invokes canScheduleExactAlarms property`() { + val repository = createRepository() + val alarmServices = mock() + val viewModel = createViewModel(repository = repository, alarmServices = alarmServices) + viewModel.canAddAlarms() + verifyInvokedOnce(alarmServices).canScheduleExactAlarms + } + @Test fun `addAlarmWithChecks() posts to showAlarmTimePicker`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn true } + val alarmServices = mock { + on { canScheduleExactAlarms } doReturn true + } val repository = createRepository() - val viewModel = createViewModel(repository, notificationHelper = notificationHelper) + val viewModel = createViewModel( + repository = repository, + notificationHelper = notificationHelper, + alarmServices = alarmServices, + runsAtLeastOnAndroidTiramisu = true, // not relevant + ) viewModel.addAlarmWithChecks() viewModel.showAlarmTimePicker.test { assertThat(awaitItem()).isEqualTo(Unit) } verifyInvokedOnce(notificationHelper).notificationsEnabled + verifyInvokedOnce(alarmServices).canScheduleExactAlarms + } + + @Test + fun `addAlarmWithChecks() posts to requestScheduleExactAlarmsPermission`() = runTest { + val notificationHelper = mock { + on { notificationsEnabled } doReturn true + } + val alarmServices = mock { + on { canScheduleExactAlarms } doReturn false + } + val repository = createRepository() + val viewModel = createViewModel( + repository = repository, + notificationHelper = notificationHelper, + alarmServices = alarmServices, + runsAtLeastOnAndroidTiramisu = true, // not relevant + ) + viewModel.addAlarmWithChecks() + viewModel.requestScheduleExactAlarmsPermission.test { + assertThat(awaitItem()).isEqualTo(Unit) + } + verifyInvokedOnce(notificationHelper).notificationsEnabled + verifyInvokedOnce(alarmServices).canScheduleExactAlarms } @Test @@ -279,7 +320,7 @@ class FahrplanViewModelTest { val viewModel = createViewModel( repository = repository, notificationHelper = notificationHelper, - runsAtLeastOnAndroidTiramisu = true + runsAtLeastOnAndroidTiramisu = true, ) viewModel.addAlarmWithChecks() viewModel.requestPostNotificationsPermission.test { @@ -297,7 +338,7 @@ class FahrplanViewModelTest { val viewModel = createViewModel( repository = repository, notificationHelper = notificationHelper, - runsAtLeastOnAndroidTiramisu = false + runsAtLeastOnAndroidTiramisu = false, ) viewModel.addAlarmWithChecks() viewModel.notificationsDisabled.test { From 0d72920100783928d4befb3052ba26f2bb638a0c Mon Sep 17 00:00:00 2001 From: Tobias Preuss Date: Sat, 27 Jan 2024 16:52:55 +0100 Subject: [PATCH 10/10] Bump application version to v.1.63.1. --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 23bdcfc987..abc74de89b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -19,8 +19,8 @@ android { buildToolsVersion = Android.buildToolsVersion defaultConfig { - versionCode 98 - versionName "1.63.0" + versionCode 99 + versionName "1.63.1" minSdk Android.minSdkVersion targetSdk Android.targetSdkVersion archivesBaseName = "Fahrplan-$versionName"