diff --git a/app/build.gradle b/app/build.gradle index 00ebb7aa5d..45917dc6d3 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" 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 new file mode 100644 index 0000000000..2f50e78900 --- /dev/null +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegate.kt @@ -0,0 +1,69 @@ +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() + + private val mutableRequestScheduleExactAlarmsPermission = Channel() + val requestScheduleExactAlarmsPermission = mutableRequestScheduleExactAlarmsPermission + .receiveAsFlow() + + fun canAddAlarms() = alarmServices.canScheduleExactAlarms + + fun addAlarmWithChecks() { + if (notificationHelper.notificationsEnabled) { + 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. + 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/SessionDetailsFragment.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/details/SessionDetailsFragment.kt index 059393b891..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 @@ -82,7 +86,8 @@ class SessionDetailsFragment : Fragment() { } } - private lateinit var permissionRequestLauncher: ActivityResultLauncher + 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 @@ -108,7 +113,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() }, @@ -132,14 +137,32 @@ class SessionDetailsFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - permissionRequestLauncher = registerForActivityResult(RequestPermission()) { isGranted -> + postNotificationsPermissionRequestLauncher = registerForActivityResult(RequestPermission()) { isGranted -> if (isGranted) { - viewModel.setAlarm() + viewModel.addAlarmWithChecks() } else { showMissingPostNotificationsPermissionError() } } + 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) } @@ -192,15 +215,8 @@ class SessionDetailsFragment : Fragment() { viewModel.addToCalendar.observe(viewLifecycleOwner) { session -> CalendarSharing(requireContext()).addToCalendar(session) } - viewModel.setAlarm.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) - } - } + viewModel.showAlarmTimePicker.observe(viewLifecycleOwner) { + showAlarmTimePicker() } viewModel.navigateToRoom.observe(viewLifecycleOwner) { uri -> startActivity(Intent(Intent.ACTION_VIEW, uri)) @@ -212,10 +228,15 @@ class SessionDetailsFragment : Fragment() { } } viewModel.requestPostNotificationsPermission.observe(viewLifecycleOwner) { - permissionRequestLauncher.launch(POST_NOTIFICATIONS) + postNotificationsPermissionRequestLauncher.launch(POST_NOTIFICATIONS) + } + viewModel.notificationsDisabled.observe(viewLifecycleOwner) { + showNotificationsDisabledError() } - viewModel.missingPostNotificationsPermission.observe(viewLifecycleOwner) { - showMissingPostNotificationsPermissionError() + viewModel.requestScheduleExactAlarmsPermission.observe(viewLifecycleOwner) { + val intent = Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM) + .setData("package:${BuildConfig.APPLICATION_ID}".toUri()) + scheduleExactAlarmsPermissionRequestLauncher.launch(intent) } } @@ -359,10 +380,29 @@ 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() } + private fun showNotificationsDisabledError() { + 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 87e2151d39..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 @@ -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,18 +108,22 @@ 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 mutableNavigateToRoom = Channel() val navigateToRoom = mutableNavigateToRoom.receiveAsFlow() private val mutableCloseDetails = Channel() val closeDetails = mutableCloseDetails.receiveAsFlow() - private val mutableRequestPostNotificationsPermission = Channel() - val requestPostNotificationsPermission = - mutableRequestPostNotificationsPermission.receiveAsFlow() - private val mutableMissingPostNotificationsPermission = Channel() - val missingPostNotificationsPermission = - mutableMissingPostNotificationsPermission.receiveAsFlow() + + val requestPostNotificationsPermission = sessionAlarmViewModelDelegate + .requestPostNotificationsPermission + + val notificationsDisabled = sessionAlarmViewModelDelegate + .notificationsDisabled + + val requestScheduleExactAlarmsPermission = sessionAlarmViewModelDelegate + .requestScheduleExactAlarmsPermission + + val showAlarmTimePicker = sessionAlarmViewModelDelegate + .showAlarmTimePicker private fun SelectedSessionParameter.customizeEngelsystemRoomName() = copy( roomName = if (roomName == defaultEngelsystemRoomName) customEngelsystemRoomName else roomName @@ -207,26 +220,23 @@ internal class SessionDetailsViewModel( } } - fun setAlarm() { - if (notificationHelper.notificationsEnabled) { - mutableSetAlarm.sendOneTimeEvent(Unit) - } else { - when (runsAtLeastOnAndroidTiramisu) { - true -> mutableRequestPostNotificationsPermission.sendOneTimeEvent(Unit) - false -> mutableMissingPostNotificationsPermission.sendOneTimeEvent(Unit) - } - } + fun canAddAlarms(): Boolean { + return sessionAlarmViewModelDelegate.canAddAlarms() + } + + fun addAlarmWithChecks() { + 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/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() 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..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 @@ -94,7 +99,8 @@ class FahrplanFragment : Fragment(), SessionViewEventsHandler { } - private lateinit var permissionRequestLauncher: ActivityResultLauncher + 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 @@ -140,14 +146,32 @@ class FahrplanFragment : Fragment(), SessionViewEventsHandler { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - permissionRequestLauncher = registerForActivityResult(RequestPermission()) { isGranted -> + postNotificationsPermissionRequestLauncher = registerForActivityResult(RequestPermission()) { isGranted -> if (isGranted) { - showAlarmTimePicker() + viewModel.addAlarmWithChecks() } else { showMissingPostNotificationsPermissionError() } } + 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) @@ -214,10 +238,15 @@ 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() } - viewModel.missingPostNotificationsPermission.observe(viewLifecycleOwner) { - showMissingPostNotificationsPermissionError() + 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() @@ -481,7 +510,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) @@ -553,6 +582,14 @@ 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 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 2d064733ad..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 @@ -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,17 @@ internal class FahrplanViewModel( private val mutableScrollToSessionParameter = Channel() val scrollToSessionParameter = mutableScrollToSessionParameter.receiveAsFlow() - private val mutableRequestPostNotificationsPermission = Channel() - val requestPostNotificationsPermission = mutableRequestPostNotificationsPermission.receiveAsFlow() + val requestPostNotificationsPermission = sessionAlarmViewModelDelegate + .requestPostNotificationsPermission + + val notificationsDisabled = sessionAlarmViewModelDelegate + .notificationsDisabled - private val mutableMissingPostNotificationsPermission = Channel() - val missingPostNotificationsPermission = mutableMissingPostNotificationsPermission.receiveAsFlow() + val requestScheduleExactAlarmsPermission = sessionAlarmViewModelDelegate + .requestScheduleExactAlarmsPermission - private val mutableShowAlarmTimePicker = Channel() - val showAlarmTimePicker = mutableShowAlarmTimePicker.receiveAsFlow() + val showAlarmTimePicker = sessionAlarmViewModelDelegate + .showAlarmTimePicker var preserveVerticalScrollPosition: Boolean = false @@ -97,17 +109,6 @@ internal class FahrplanViewModel( updateUncanceledSessions() } - fun showAlarmTimePickerWithChecks() { - if (notificationHelper.notificationsEnabled) { - mutableShowAlarmTimePicker.sendOneTimeEvent(Unit) - } else { - when (runsAtLeastOnAndroidTiramisu) { - true -> mutableRequestPostNotificationsPermission.sendOneTimeEvent(Unit) - false -> mutableMissingPostNotificationsPermission.sendOneTimeEvent(Unit) - } - } - } - private fun updateUncanceledSessions() { launch { repository.uncanceledSessionsForDayIndex.collect { scheduleData -> @@ -248,15 +249,23 @@ internal class FahrplanViewModel( } } + fun canAddAlarms(): Boolean { + return sessionAlarmViewModelDelegate.canAddAlarms() + } + + 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/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c9d1330ebb..58c8570429 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -203,6 +203,8 @@ 60 Minuten vorher 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 3e0bd067d2..ce6ae0be59 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -234,6 +234,8 @@ 60 minutes before 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 new file mode 100644 index 0000000000..0776e21bfe --- /dev/null +++ b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/alarms/SessionAlarmViewModelDelegateTest.kt @@ -0,0 +1,120 @@ +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 `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 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 { + 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, + ) + +} 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..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 @@ -305,20 +305,61 @@ class SessionDetailsViewModelTest { } @Test - fun `setAlarm() posts to setAlarm`() = runTest { + 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 = 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, notificationHelper = notificationHelper) - viewModel.setAlarm() - viewModel.setAlarm.test { + 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 - fun `setAlarm() posts to requestPostNotificationsPermission`() = runTest { + fun `addAlarmWithChecks() posts to requestPostNotificationsPermission as of Android 13`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn false } @@ -326,16 +367,17 @@ class SessionDetailsViewModelTest { val viewModel = createViewModel( repository = repository, notificationHelper = notificationHelper, - runsAtLeastOnAndroidTiramisu = true + runsAtLeastOnAndroidTiramisu = true, ) - viewModel.setAlarm() + viewModel.addAlarmWithChecks() viewModel.requestPostNotificationsPermission.test { assertThat(awaitItem()).isEqualTo(Unit) } + verifyInvokedOnce(notificationHelper).notificationsEnabled } @Test - fun `setAlarm() posts to missingPostNotificationsPermission`() = runTest { + fun `addAlarmWithChecks() posts to notificationsDisabled before Android 13`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn false } @@ -343,10 +385,10 @@ class SessionDetailsViewModelTest { val viewModel = createViewModel( repository = repository, notificationHelper = notificationHelper, - runsAtLeastOnAndroidTiramisu = false + runsAtLeastOnAndroidTiramisu = false, ) - viewModel.setAlarm() - viewModel.missingPostNotificationsPermission.test { + 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 871c3598d5..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 @@ -257,20 +257,62 @@ class FahrplanViewModelTest { } @Test - fun `showAlarmTimePickerWithChecks() posts to showAlarmTimePicker`() = runTest { + 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) - viewModel.showAlarmTimePickerWithChecks() + 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 - fun `showAlarmTimePickerWithChecks() posts to requestPostNotificationsPermission`() = runTest { + fun `addAlarmWithChecks() posts to requestPostNotificationsPermission as of Android 13`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn false } @@ -278,16 +320,17 @@ class FahrplanViewModelTest { val viewModel = createViewModel( repository = repository, notificationHelper = notificationHelper, - runsAtLeastOnAndroidTiramisu = true + runsAtLeastOnAndroidTiramisu = true, ) - viewModel.showAlarmTimePickerWithChecks() + viewModel.addAlarmWithChecks() viewModel.requestPostNotificationsPermission.test { assertThat(awaitItem()).isEqualTo(Unit) } + verifyInvokedOnce(notificationHelper).notificationsEnabled } @Test - fun `showAlarmTimePickerWithChecks() posts to missingPostNotificationsPermission`() = runTest { + fun `addAlarmWithChecks() posts to notificationsDisabled before Android 13`() = runTest { val notificationHelper = mock { on { notificationsEnabled } doReturn false } @@ -295,10 +338,10 @@ class FahrplanViewModelTest { val viewModel = createViewModel( repository = repository, notificationHelper = notificationHelper, - runsAtLeastOnAndroidTiramisu = false + runsAtLeastOnAndroidTiramisu = false, ) - viewModel.showAlarmTimePickerWithChecks() - viewModel.missingPostNotificationsPermission.test { + viewModel.addAlarmWithChecks() + viewModel.notificationsDisabled.test { assertThat(awaitItem()).isEqualTo(Unit) } }