From c52c6c54cc613eec64abeb97f55ed25dedba0023 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 13 Nov 2026 17:00:46 +0100 Subject: [PATCH] feat(#171): full-screen reminder alerts per calendar --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 34 +++- .../calendula/data/prefs/SettingsPrefs.kt | 12 ++ .../data/reminders/FullScreenAlerts.kt | 33 ++++ .../data/reminders/ReminderActionReceiver.kt | 31 +-- .../data/reminders/ReminderChannelPlan.kt | 3 + .../data/reminders/ReminderChannels.kt | 37 +++- .../data/reminders/ReminderNotifier.kt | 127 ++++++++++-- .../data/reminders/ReminderRingService.kt | 160 ++++++++++++++++ .../domain/reminders/ReminderAlertStyle.kt | 43 +++++ .../ui/reminders/ReminderAlertActivity.kt | 161 ++++++++++++++++ .../ui/reminders/ReminderAlertScreen.kt | 180 ++++++++++++++++++ .../ui/reminders/ReminderAlertUiState.kt | 16 ++ .../ui/settings/NotificationSettings.kt | 117 +++++++++++- .../calendula/ui/settings/SettingsUiState.kt | 3 + .../ui/settings/SettingsViewModel.kt | 4 + app/src/main/res/values/strings.xml | 14 ++ .../calendula/data/prefs/SettingsPrefsTest.kt | 13 ++ .../reminders/ReminderAlertStyleTest.kt | 38 ++++ docs/ARCHITECTURE.md | 18 ++ docs/PRIVACY.md | 4 +- floret-kit | 2 +- 22 files changed, 1005 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/FullScreenAlerts.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderRingService.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderAlertStyle.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/reminders/ReminderAlertActivity.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/reminders/ReminderAlertScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/reminders/ReminderAlertUiState.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderAlertStyleTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 409adcb7..27bece29 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -182,6 +182,7 @@ dependencies { implementation("de.jeanlucmakiola.floret:identity") implementation("de.jeanlucmakiola.floret:components") implementation("de.jeanlucmakiola.floret:glance") + implementation("de.jeanlucmakiola.floret:ring") implementation(libs.kotlinx.coroutines.core) debugImplementation(libs.androidx.ui.tooling) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b272d8e9..268f5624 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -41,10 +41,20 @@ --> - + - - + + + + + + + + + + + + > = prefs.flow(SettingsKeys.perCalendarEventDuration) val remindersEnabled: Flow = prefs.flow(SettingsKeys.remindersEnabled) val mutedReminderCalendarIds: Flow> = prefs.flow(SettingsKeys.mutedReminderCalendarIds) + val reminderAlertStyles: Flow> = prefs.flow(SettingsKeys.reminderAlertStyles) val allowColorOnUnsupportedCalendars: Flow = prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars) val defaultReminderMinutes: Flow> = prefs.flow(SettingsKeys.defaultReminderMinutes) @@ -278,6 +282,10 @@ class SettingsPrefs @Inject constructor( prefs.update(SettingsKeys.mutedReminderCalendarIds) { if (enabled) it - calendarId else it + calendarId } } + suspend fun setCalendarAlertStyle(calendarId: Long, style: ReminderAlertStyle) { + prefs.update(SettingsKeys.reminderAlertStyles) { it + (calendarId to style) } + } + suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) = prefs.set(SettingsKeys.allowColorOnUnsupportedCalendars, enabled) @@ -605,6 +613,10 @@ object SettingsKeys { */ val mutedReminderCalendarIds: Pref> = idSetPref("muted_reminder_calendar_ids") + /** Per-calendar alert styles (#171); a calendar absent from the map uses plain notifications. */ + val reminderAlertStyles: Pref> = + nullableStringPref("reminder_alert_styles").map(::parseAlertStyles) { it.toStoredAlertStyles() } + /** * Whether to offer a custom event colour on calendars that publish no colour * palette; synced ones without a palette (some CalDAV) may drop or overwrite diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/FullScreenAlerts.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/FullScreenAlerts.kt new file mode 100644 index 00000000..1ae01fa0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/FullScreenAlerts.kt @@ -0,0 +1,33 @@ +package de.jeanlucmakiola.calendula.data.reminders + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * What the full-screen alerts (#171) share within the process: the reminder + * [ReminderRingService] is ringing for, and the reminders just dismissed or + * snoozed, so an alert screen showing one closes whichever way it was handled. + */ +@Singleton +class FullScreenAlerts @Inject constructor() { + + /** Set by [ReminderRingService] for as long as it rings. */ + @Volatile + var ringing: ReminderAlert? = null + + private val handledKeys = MutableSharedFlow( + extraBufferCapacity = 8, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** The [ReminderAlert.key]s of reminders as they are dismissed or snoozed. */ + val handled: SharedFlow = handledKeys.asSharedFlow() + + fun markHandled(alert: ReminderAlert) { + handledKeys.tryEmit(alert.key) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt index c0bd7569..7329fd06 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt @@ -13,8 +13,8 @@ import javax.inject.Inject /** * Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus * the internal re-show when a snooze elapses. All three are app-internal - * intents (notification action buttons and our own [ReminderSnoozeScheduler] - * alarm), so the receiver is not exported. + * intents (notification action buttons, the full-screen alert's buttons and + * our own [ReminderSnoozeScheduler] alarm), so the receiver is not exported. * * - **Dismiss** just cancels the notification — the scan's watermark has moved * past this reminder, so nothing re-posts it. @@ -74,6 +74,9 @@ class ReminderActionReceiver : BroadcastReceiver() { */ const val ACTION_OPEN = "de.jeanlucmakiola.calendula.reminders.OPEN" + /** Not handled here either: the slot of the full-screen alert's own intent (#171). */ + const val ACTION_ALERT_SCREEN = "de.jeanlucmakiola.calendula.reminders.ALERT_SCREEN" + private const val EXTRA_ALERT_KEY = "alert_key" private const val EXTRA_EVENT_ID = "event_id" private const val EXTRA_CALENDAR_ID = "calendar_id" @@ -94,16 +97,21 @@ class ReminderActionReceiver : BroadcastReceiver() { Intent(context, ReminderActionReceiver::class.java).apply { this.action = action data = "calendula://reminder/${identity(alert, action)}".toUri() - putExtra(EXTRA_ALERT_KEY, alert.key) - putExtra(EXTRA_EVENT_ID, alert.eventId) - putExtra(EXTRA_CALENDAR_ID, alert.calendarId) - putExtra(EXTRA_BEGIN, alert.beginMillis) - putExtra(EXTRA_END, alert.endMillis) - putExtra(EXTRA_TITLE, alert.title) - putExtra(EXTRA_LOCATION, alert.location) - putExtra(EXTRA_ALL_DAY, alert.isAllDay) + putAlert(alert) } + /** Carries [alert] as extras, for [alertFrom] to read back. */ + fun Intent.putAlert(alert: ReminderAlert): Intent = apply { + putExtra(EXTRA_ALERT_KEY, alert.key) + putExtra(EXTRA_EVENT_ID, alert.eventId) + putExtra(EXTRA_CALENDAR_ID, alert.calendarId) + putExtra(EXTRA_BEGIN, alert.beginMillis) + putExtra(EXTRA_END, alert.endMillis) + putExtra(EXTRA_TITLE, alert.title) + putExtra(EXTRA_LOCATION, alert.location) + putExtra(EXTRA_ALL_DAY, alert.isAllDay) + } + /** * A stable request code per (alert, action), so one notification's * PendingIntents stay distinct. The shift keeps the action slot intact; @@ -115,6 +123,7 @@ class ReminderActionReceiver : BroadcastReceiver() { ACTION_DISMISS -> 2 ACTION_SHOW -> 3 ACTION_OPEN -> 4 + ACTION_ALERT_SCREEN -> 5 else -> 0 } return (identity(alert, action).toInt() shl 3) + actionOffset @@ -123,7 +132,7 @@ class ReminderActionReceiver : BroadcastReceiver() { private fun identity(alert: ReminderAlert, action: String): Long = if (action == ACTION_SHOW) alert.occurrenceKey else alert.key - private fun alertFrom(intent: Intent): ReminderAlert? { + fun alertFrom(intent: Intent): ReminderAlert? { if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null return ReminderAlert( key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannelPlan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannelPlan.kt index 1f070222..da13a7cf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannelPlan.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannelPlan.kt @@ -6,6 +6,9 @@ import de.jeanlucmakiola.calendula.ui.common.groupByAccount /** The single channel every reminder posted on before they moved per calendar (#145). */ const val LEGACY_REMINDER_CHANNEL_ID = "reminders" +/** The silent channel ringing full-screen alerts post on (#171); outside the per-calendar namespace. */ +const val RING_CHANNEL_ID = "reminders_ring" + private const val CHANNEL_PREFIX = "reminders.calendar." private const val GROUP_PREFIX = "reminders.account." private const val LOCAL_GROUP_ID = GROUP_PREFIX + "local" diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannels.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannels.kt index 2f80a55c..87894f57 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannels.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannels.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.data.reminders +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationChannelGroup import android.app.NotificationManager @@ -9,6 +10,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle import de.jeanlucmakiola.calendula.ui.common.sourceAppLabel import java.util.Locale import javax.inject.Inject @@ -72,10 +74,41 @@ class ReminderChannels @Inject constructor( * has not got to it yet. Null when the calendar no longer exists. Should the * calendar lookup fail or come back empty, the channel is still created, * plainly named and ungrouped, for the next sync to name and file — a - * reminder is never lost to its channel. + * reminder is never lost to its channel. A ringing [style] posts on the + * shared ring channel instead, once the calendar is known to exist. */ @Synchronized - fun channelFor(calendarId: Long): String? { + fun channelFor(calendarId: Long, style: ReminderAlertStyle = ReminderAlertStyle.Notification): String? { + val calendarChannel = calendarChannelFor(calendarId) ?: return null + return if (style == ReminderAlertStyle.FullScreenRing) ringChannel() else calendarChannel + } + + /** + * The channel a ringing full-screen alert (#171) posts on, shared by every + * calendar. Silent: the ring service owns the sound and vibration, so the + * channel adds neither. High importance keeps the heads-up a denied + * full-screen intent degrades to. + */ + private fun ringChannel(): String { + val manager = manager + if (manager.getNotificationChannel(RING_CHANNEL_ID) == null) { + manager.createNotificationChannel( + NotificationChannel( + RING_CHANNEL_ID, + context.getString(R.string.reminder_ring_channel_name), + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = context.getString(R.string.reminder_ring_channel_description) + setSound(null, null) + enableVibration(false) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + }, + ) + } + return RING_CHANNEL_ID + } + + private fun calendarChannelFor(calendarId: Long): String? { val id = reminderChannelId(calendarId) val manager = manager if (manager.getNotificationChannel(id) != null) return id diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt index 3631482e..d5b1dcb3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.data.reminders import android.Manifest +import android.app.Notification import android.app.PendingIntent import android.content.Context import android.os.Build @@ -16,7 +17,11 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay +import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle +import de.jeanlucmakiola.calendula.domain.reminders.alertStyleFor +import de.jeanlucmakiola.calendula.ui.reminders.ReminderAlertActivity import de.jeanlucmakiola.floret.android.hasPermission +import de.jeanlucmakiola.floret.ring.canUseFullScreenIntent import kotlinx.coroutines.flow.first import kotlinx.datetime.isoDayNumber import java.time.DayOfWeek @@ -28,7 +33,8 @@ import javax.inject.Singleton /** * Posts one notification per occurrence on its calendar's channel - * ([ReminderChannels], #145). Tapping opens the event's detail screen. + * ([ReminderChannels], #145), or as a full-screen alert where the calendar + * asks for one (#171). Tapping opens the event's detail screen. * * The tag is the [occurrenceKey], so each reminder for an occurrence replaces * the one before it in place (#463). A different reminder alerts afresh; the @@ -42,6 +48,7 @@ class ReminderNotifier @Inject constructor( private val calendarPrefs: CalendarPrefs, private val calendarDataSource: CalendarDataSource, private val channels: ReminderChannels, + private val alerts: FullScreenAlerts, ) { /** False when the user declined `POST_NOTIFICATIONS` or muted the app. */ @@ -62,16 +69,54 @@ class ReminderNotifier @Inject constructor( calendarId in calendarPrefs.pendingDisabledCalendarIds.first() || calendarDataSource.isCalendarVisible(calendarId) == false - /** Post [alert], unless its calendar is silenced or gone. */ - suspend fun post(alert: ReminderAlert) { + /** + * Post [alert] in its calendar's alert style (#171), unless the calendar is + * silenced or gone. [quiet] posts it as a plain notification that makes no + * sound: a ring handing it back to the shade. + */ + suspend fun post(alert: ReminderAlert, quiet: Boolean = false) { if (isSilenced(alert.calendarId)) return - val channelId = try { - channels.channelFor(alert.calendarId) - } catch (e: Exception) { - Log.w(TAG, "No channel for the reminder of event ${alert.eventId}", e) - null - } ?: return + val style = if (quiet) { + ReminderAlertStyle.Notification + } else { + settingsPrefs.reminderAlertStyles.first().alertStyleFor(alert.calendarId) + } + val channelId = channelFor(alert, style) ?: return val title = displayTitle(alert) + val text = contentText(alert) + if (style == ReminderAlertStyle.FullScreenRing) { + val ringing = build(alert, channelId, title, text, fullScreen = true, ringing = true, quiet = false) + if (ReminderRingService.start(context, alert, ringing)) { + // An earlier reminder for this occurrence gives way to the ring. + NotificationManagerCompat.from(context).cancel(alert.occurrenceKey.toString(), NOTIFICATION_ID) + cancelLegacy(alert) + return + } + // Android refused the ring: still full-screen, sounding once on the calendar's channel. + val fallback = channelFor(alert, ReminderAlertStyle.FullScreen) ?: return + notify(alert, build(alert, fallback, title, text, fullScreen = true, ringing = false, quiet = false)) + return + } + // A ring for this occurrence from before its calendar's style changed. + if (alerts.ringing?.occurrenceKey == alert.occurrenceKey) ReminderRingService.stop(context) + notify( + alert, + build(alert, channelId, title, text, fullScreen = style.isFullScreen, ringing = false, quiet = quiet), + ) + } + + private fun channelFor(alert: ReminderAlert, style: ReminderAlertStyle): String? = try { + channels.channelFor(alert.calendarId, style) + } catch (e: Exception) { + Log.w(TAG, "No channel for the reminder of event ${alert.eventId}", e) + null + } + + private suspend fun contentText(alert: ReminderAlert): String = + listOfNotNull(timeText(alert), alert.location).joinToString(" · ") + + /** When [alert]'s occurrence happens, as its notification words it. */ + suspend fun timeText(alert: ReminderAlert): String { val is24Hour = settingsPrefs.timeFormat.first() .is24Hour(android.text.format.DateFormat.is24HourFormat(context)) val zone = ZoneId.systemDefault() @@ -79,7 +124,7 @@ class ReminderNotifier @Inject constructor( // resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by // its shared ISO number (1..7) for the date math in reminderTimeText. val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber) - val time = reminderTimeText( + return reminderTimeText( beginMillis = alert.beginMillis, endMillis = alert.endMillis, isAllDay = alert.isAllDay, @@ -92,19 +137,34 @@ class ReminderNotifier @Inject constructor( tomorrowLabel = context.getString(R.string.reminder_day_tomorrow), yesterdayLabel = context.getString(R.string.reminder_day_yesterday), ) - val text = listOfNotNull(time, alert.location).joinToString(" · ") - val notification = NotificationCompat.Builder(context, channelId) + } + + /** + * The reminder's notification. A [ringing] one belongs to [ReminderRingService]: + * ongoing, opening the alert screen, and dismissed by a swipe, since from + * Android 14 a swipe can take it away while the ring goes on. [fullScreen] + * adds the alert screen as a full-screen intent where Android allows it, + * and degrades to a heads-up where it does not. + */ + private fun build( + alert: ReminderAlert, + channelId: String, + title: String, + text: String, + fullScreen: Boolean, + ringing: Boolean, + quiet: Boolean, + ): Notification { + val builder = NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(title) .setContentText(text) .setWhen(alert.beginMillis) .setShowWhen(false) - .setCategory(NotificationCompat.CATEGORY_EVENT) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) - .setOnlyAlertOnce(shownKey(alert) == alert.key) + .setCategory(if (ringing) NotificationCompat.CATEGORY_ALARM else NotificationCompat.CATEGORY_EVENT) + .setPriority(if (ringing) NotificationCompat.PRIORITY_MAX else NotificationCompat.PRIORITY_HIGH) .addExtras(Bundle().apply { putLong(EXTRA_REMINDER_KEY, alert.key) }) - .setContentIntent(detailIntent(alert)) + .setContentIntent(if (ringing) alertScreenIntent(alert) else detailIntent(alert)) .addAction( R.drawable.ic_notification_snooze, context.getString(R.string.reminder_action_snooze), @@ -115,7 +175,19 @@ class ReminderNotifier @Inject constructor( context.getString(R.string.reminder_action_dismiss), actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS), ) - .build() + if (ringing) { + builder.setOngoing(true) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setDeleteIntent(actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS)) + } else { + builder.setAutoCancel(true).setOnlyAlertOnce(shownKey(alert) == alert.key) + } + if (quiet) builder.setSilent(true) + if (fullScreen && context.canUseFullScreenIntent()) builder.setFullScreenIntent(alertScreenIntent(alert), true) + return builder.build() + } + + private fun notify(alert: ReminderAlert, notification: Notification) { val manager = NotificationManagerCompat.from(context) cancelLegacy(alert) try { @@ -136,10 +208,20 @@ class ReminderNotifier @Inject constructor( if (shownKey(alert) == alert.key) manager.cancel(alert.occurrenceKey.toString(), NOTIFICATION_ID) // Posted before reminders shared a notification, under the reminder's own key. manager.cancel(alert.key.toString(), NOTIFICATION_ID) + if (alerts.ringing?.key == alert.key) ReminderRingService.stop(context) + alerts.markHandled(alert) } /** Whether a different reminder for [alert]'s occurrence is on screen now. */ - fun isSuperseded(alert: ReminderAlert): Boolean = shownKey(alert).let { it != null && it != alert.key } + fun isSuperseded(alert: ReminderAlert): Boolean { + val ringing = alerts.ringing?.takeIf { it.occurrenceKey == alert.occurrenceKey } + if (ringing != null) return ringing.key != alert.key + return shownKey(alert).let { it != null && it != alert.key } + } + + /** Whether [alert] itself is still on screen, as a notification or ringing. */ + fun isShowing(alert: ReminderAlert): Boolean = + alerts.ringing?.key == alert.key || shownKey(alert) == alert.key /** The key of the reminder showing for [alert]'s occurrence, if any. */ private fun shownKey(alert: ReminderAlert): Long? { @@ -176,6 +258,13 @@ class ReminderNotifier @Inject constructor( PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) + private fun alertScreenIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity( + context, + ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_ALERT_SCREEN), + ReminderAlertActivity.intent(context, alert), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity( context, // Shares the per-(alert, action) request-code scheme with the buttons. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderRingService.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderRingService.kt new file mode 100644 index 00000000..97e59fa9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderRingService.kt @@ -0,0 +1,160 @@ +package de.jeanlucmakiola.calendula.data.reminders + +import android.app.Notification +import android.app.Service +import android.content.Context +import android.content.Intent +import android.media.RingtoneManager +import android.os.IBinder +import android.util.Log +import androidx.core.content.IntentCompat +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.calendula.data.reminders.ReminderActionReceiver.Companion.putAlert +import de.jeanlucmakiola.floret.ring.RingVibrator +import de.jeanlucmakiola.floret.ring.RingWakeLock +import de.jeanlucmakiola.floret.ring.TonePlayer +import de.jeanlucmakiola.floret.ring.startRingForeground +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Rings a reminder whose calendar asks for "Ring until dismissed" (#171): the + * alarm sound looped with vibration, for as long as the reminder is neither + * dismissed nor snoozed, up to [RING_TIMEOUT_MILLIS]. Then it goes quiet and + * hands the reminder back to the shade as an ordinary notification. + * + * One reminder rings at a time. A new one takes over, and the one it replaces + * stays in the shade, silently, unless it was for the same occurrence. + */ +@AndroidEntryPoint +class ReminderRingService : Service() { + + @Inject lateinit var alerts: FullScreenAlerts + @Inject lateinit var notifier: ReminderNotifier + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + private val tones by lazy { TonePlayer(this) } + private val vibrator by lazy { RingVibrator(this) } + private val wakeLock by lazy { RingWakeLock(this, WAKE_LOCK_TAG, RING_TIMEOUT_MILLIS) } + + private var ringing: Job? = null + private var timeout: Job? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val alert = intent?.let(ReminderActionReceiver::alertFrom) + val notification = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION, Notification::class.java) } + if (alert == null || notification == null) { + if (alerts.ringing == null) stopSelf() + return START_NOT_STICKY + } + // Before anything else: the platform gives a foreground service a few + // seconds to post its notification. + startRingForeground(RING_NOTIFICATION_ID, notification) + val previous = alerts.ringing + if (previous?.key == alert.key && timeout?.isActive == true) return START_REDELIVER_INTENT + alerts.ringing = alert + if (previous != null && previous.occurrenceKey != alert.occurrenceKey) { + scope.launch { notifier.post(previous, quiet = true) } + } + ring() + // Re-delivered rather than dropped, so a ring the system killed comes back. + return START_REDELIVER_INTENT + } + + override fun onDestroy() { + releaseRing() + alerts.ringing = null + scope.cancel() + super.onDestroy() + } + + private fun ring() { + releaseRing() + wakeLock.acquire() + // Vibration first: the sound may take a moment to open. + vibrator.start() + ringing = scope.launch { tones.play(scope, ringSources()) } + timeout = scope.launch { + delay(RING_TIMEOUT_MILLIS) + giveUp() + } + } + + /** Rung long enough: back to the shade quietly, so the reminder is not lost. */ + private suspend fun giveUp() { + timeout = null + val alert = alerts.ringing + releaseRing() + alerts.ringing = null + if (alert != null) { + try { + notifier.post(alert, quiet = true) + } catch (e: Exception) { + Log.w(TAG, "Could not hand the reminder of event ${alert.eventId} back", e) + } + } + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private fun releaseRing() { + ringing?.cancel() + ringing = null + timeout?.cancel() + timeout = null + tones.stop() + vibrator.stop() + wakeLock.release() + } + + /** The device's alarm sound, then the built-in ones; none opening leaves the vibration. */ + private fun ringSources() = listOfNotNull( + RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_ALARM), + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM), + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), + ) + + companion object { + private const val TAG = "ReminderRingService" + private const val EXTRA_NOTIFICATION = "notification" + private const val WAKE_LOCK_TAG = "calendula:reminder-ring" + + /** Distinct from the reminders' own id, which is shared under per-occurrence tags. */ + private const val RING_NOTIFICATION_ID = 2 + + /** How long a reminder rings unanswered before it goes quiet. */ + const val RING_TIMEOUT_MILLIS = 10L * 60 * 1000 + + /** + * Rings [alert] under [notification]. False when Android refused to + * start the service from where the reminder fired. + */ + fun start(context: Context, alert: ReminderAlert, notification: Notification): Boolean { + val intent = Intent(context, ReminderRingService::class.java) + .putAlert(alert) + .putExtra(EXTRA_NOTIFICATION, notification) + return try { + RingWakeLock.handOff(context, WAKE_LOCK_TAG) + context.startForegroundService(intent) + true + } catch (e: Exception) { + Log.w(TAG, "Could not start the ring for event ${alert.eventId}", e) + false + } + } + + /** `stopService`, not a command: a command is a start, which can throw from the background. */ + fun stop(context: Context) { + context.stopService(Intent(context, ReminderRingService::class.java)) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderAlertStyle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderAlertStyle.kt new file mode 100644 index 00000000..850efaad --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderAlertStyle.kt @@ -0,0 +1,43 @@ +package de.jeanlucmakiola.calendula.domain.reminders + +/** How a calendar's reminders get the user's attention (#171). */ +enum class ReminderAlertStyle(val storageValue: String) { + /** An ordinary notification on the calendar's channel. */ + Notification("notification"), + + /** A full-screen alert that sounds the calendar's notification sound once and stays until handled. */ + FullScreen("full_screen"), + + /** A full-screen alert that rings and vibrates like an alarm until handled. */ + FullScreenRing("full_screen_ring"), + ; + + val isFullScreen: Boolean get() = this != Notification + + companion object { + fun fromStorage(value: String): ReminderAlertStyle? = entries.firstOrNull { it.storageValue == value } + } +} + +/** [calendarId]'s alert style; a calendar without one uses plain notifications. */ +fun Map.alertStyleFor(calendarId: Long): ReminderAlertStyle = + this[calendarId] ?: ReminderAlertStyle.Notification + +/** + * Parse `id=style` entries joined by `;`. Malformed entries and unknown styles + * are dropped, so a garbled value degrades to plain notifications. + */ +fun parseAlertStyles(stored: String?): Map = + stored?.split(";").orEmpty().mapNotNull { entry -> + val parts = entry.split("=") + if (parts.size != 2) return@mapNotNull null + val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null + val style = ReminderAlertStyle.fromStorage(parts[1].trim()) ?: return@mapNotNull null + calendarId to style + }.toMap() + +/** The stored form of [parseAlertStyles]; plain-notification entries are left out. */ +fun Map.toStoredAlertStyles(): String = + entries.filter { it.value != ReminderAlertStyle.Notification } + .sortedBy { it.key } + .joinToString(";") { (id, style) -> "$id=${style.storageValue}" } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/reminders/ReminderAlertActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/reminders/ReminderAlertActivity.kt new file mode 100644 index 00000000..ce74b5e1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/reminders/ReminderAlertActivity.kt @@ -0,0 +1,161 @@ +package de.jeanlucmakiola.calendula.ui.reminders + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.data.reminders.FullScreenAlerts +import de.jeanlucmakiola.calendula.data.reminders.ReminderActionReceiver +import de.jeanlucmakiola.calendula.data.reminders.ReminderActionReceiver.Companion.putAlert +import de.jeanlucmakiola.calendula.data.reminders.ReminderAlert +import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier +import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors +import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme +import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache +import de.jeanlucmakiola.floret.di.IoDispatcher +import de.jeanlucmakiola.floret.prefs.ThemeMode +import de.jeanlucmakiola.floret.prefs.isDark +import de.jeanlucmakiola.floret.prefs.isPureBlack +import de.jeanlucmakiola.floret.ring.showOverLockScreen +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * The full-screen reminder alert (#171), opened by the reminder's full-screen + * intent over the lock screen, or from its heads-up while the phone is in use. + * It stays until the reminder is dismissed or snoozed — here, from the + * notification, or by the ring giving up — and back does not close it. + */ +@AndroidEntryPoint +class ReminderAlertActivity : ComponentActivity() { + + @Inject lateinit var notifier: ReminderNotifier + @Inject lateinit var alerts: FullScreenAlerts + @Inject lateinit var settingsPrefs: SettingsPrefs + @Inject lateinit var calendarDataSource: CalendarDataSource + + @Inject @IoDispatcher + lateinit var io: CoroutineDispatcher + + private var state by mutableStateOf(ReminderAlertUiState.Loading) + private var alert: ReminderAlert? = null + private var loading: Job? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + showOverLockScreen() + enableEdgeToEdge() + show(intent) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + alerts.handled.collect { key -> if (key == alert?.key) finish() } + } + } + val cachedTheme = WindowThemeCache.read(this) ?: ThemeMode.SYSTEM + val cachedDynamicColor = WindowThemeCache.readDynamicColor(this) ?: true + setContent { + val themeMode by settingsPrefs.themeMode.collectAsStateWithLifecycle(cachedTheme) + val dynamicColor by settingsPrefs.dynamicColor.collectAsStateWithLifecycle(cachedDynamicColor) + val soften by settingsPrefs.softenCalendarColors.collectAsStateWithLifecycle(true) + CalendulaTheme( + darkTheme = themeMode.isDark(isSystemInDarkTheme()), + dynamicColor = dynamicColor, + pureBlack = themeMode.isPureBlack, + ) { + CompositionLocalProvider(LocalSoftenColors provides soften) { + BackHandler(enabled = true) { /* Handled by Dismiss or Snooze, not by leaving. */ } + when (val current = state) { + ReminderAlertUiState.Loading -> + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} + is ReminderAlertUiState.Showing -> ReminderAlertScreen( + state = current, + onSnooze = { act(ReminderActionReceiver.ACTION_SNOOZE) }, + onDismiss = { act(ReminderActionReceiver.ACTION_DISMISS) }, + ) + } + } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + show(intent) + } + + /** Tapped from a stale notification, or handled while this window was away. */ + override fun onResume() { + super.onResume() + val current = alert + if (current != null && !notifier.isShowing(current)) finish() + } + + private fun show(intent: Intent) { + val next = ReminderActionReceiver.alertFrom(intent) + if (next == null) { + finish() + return + } + alert = next + loading?.cancel() + state = ReminderAlertUiState.Loading + loading = lifecycleScope.launch { + val calendar = try { + withContext(io) { calendarDataSource.calendars().firstOrNull { it.id == next.calendarId } } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // The alert still shows, only without its calendar. + Log.w(TAG, "Could not read the calendar of event ${next.eventId}", e) + null + } + state = ReminderAlertUiState.Showing( + title = next.title, + time = notifier.timeText(next), + location = next.location?.takeIf { it.isNotBlank() }, + calendarName = calendar?.displayName, + calendarColor = calendar?.color, + ) + } + } + + private fun act(action: String) { + val current = alert ?: return + sendBroadcast(ReminderActionReceiver.intent(this, action, current)) + finish() + } + + companion object { + private const val TAG = "ReminderAlertActivity" + + fun intent(context: Context, alert: ReminderAlert): Intent = + Intent(context, ReminderAlertActivity::class.java) + .putAlert(alert) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/reminders/ReminderAlertScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/reminders/ReminderAlertScreen.kt new file mode 100644 index 00000000..ea972e6d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/reminders/ReminderAlertScreen.kt @@ -0,0 +1,180 @@ +package de.jeanlucmakiola.calendula.ui.reminders + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Event +import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material.icons.outlined.Snooze +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors +import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.eventInk +import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme +import de.jeanlucmakiola.floret.ring.RingBadge + +/** + * The alert itself, after the incoming-call screen: the calendar's pulsing + * badge, what and when in the middle, and the two ways out at the bottom, + * large enough to hit half awake. + */ +@Composable +internal fun ReminderAlertScreen( + state: ReminderAlertUiState.Showing, + onSnooze: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainer) { + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + state.calendarName?.let { + Text( + text = it, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.weight(1f)) + CalendarBadge(state.calendarColor) + Spacer(Modifier.size(32.dp)) + Text( + text = state.title.ifBlank { stringResource(R.string.event_untitled) }, + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.size(12.dp)) + Text( + text = state.time, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + state.location?.let { location -> + Spacer(Modifier.size(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Outlined.LocationOn, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.size(4.dp)) + Text( + text = location, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + Spacer(Modifier.weight(1.5f)) + Row( + modifier = Modifier.fillMaxWidth().widthIn(max = 480.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + AlertButton( + label = stringResource(R.string.reminder_action_snooze), + icon = Icons.Outlined.Snooze, + tonal = true, + onClick = onSnooze, + modifier = Modifier.weight(1f), + ) + AlertButton( + label = stringResource(R.string.reminder_action_dismiss), + icon = Icons.Rounded.Close, + tonal = false, + onClick = onDismiss, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +/** The calendar's colour as the badge fill, or the theme's primary container without one. */ +@Composable +private fun CalendarBadge(color: Int?) { + if (color == null) { + RingBadge(icon = Icons.Outlined.Event, size = 120.dp) + return + } + val fill = eventFill(color, LocalFloretDarkTheme.current, LocalSoftenColors.current) + RingBadge( + icon = Icons.Outlined.Event, + containerColor = fill, + contentColor = eventInk(fill, alpha = 1f), + size = 120.dp, + ) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun AlertButton( + label: String, + icon: ImageVector, + tonal: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val height = ButtonDefaults.LargeContainerHeight + val content: @Composable () -> Unit = { + Icon(icon, contentDescription = null, modifier = Modifier.size(ButtonDefaults.iconSizeFor(height))) + Spacer(Modifier.size(ButtonDefaults.iconSpacingFor(height))) + Text(label, style = ButtonDefaults.textStyleFor(height), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + val buttonModifier = modifier.heightIn(min = height) + val padding = ButtonDefaults.contentPaddingFor(height) + if (tonal) { + FilledTonalButton( + onClick = onClick, + modifier = buttonModifier, + shapes = ButtonDefaults.shapesFor(height), + contentPadding = padding, + ) { content() } + } else { + Button( + onClick = onClick, + modifier = buttonModifier, + shapes = ButtonDefaults.shapesFor(height), + contentPadding = padding, + ) { content() } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/reminders/ReminderAlertUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/reminders/ReminderAlertUiState.kt new file mode 100644 index 00000000..8368a571 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/reminders/ReminderAlertUiState.kt @@ -0,0 +1,16 @@ +package de.jeanlucmakiola.calendula.ui.reminders + +sealed interface ReminderAlertUiState { + /** The calendar is still being read; the surface is already up. */ + data object Loading : ReminderAlertUiState + + data class Showing( + /** Raw title; blank shows as "(no title)". */ + val title: String, + val time: String, + val location: String?, + /** Null when the calendar could not be read. */ + val calendarName: String?, + val calendarColor: Int?, + ) : ReminderAlertUiState +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt index b87b4b9c..48b387c0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt @@ -34,6 +34,8 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle +import de.jeanlucmakiola.calendula.domain.reminders.alertStyleFor import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.DurationPicker import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS @@ -47,11 +49,14 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.ExpandIcon import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedSwitchRow +import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.reminders.ReminderOverride +import de.jeanlucmakiola.floret.ring.canUseFullScreenIntent +import de.jeanlucmakiola.floret.ring.openFullScreenIntentSettings import de.jeanlucmakiola.floret.reminders.minutesOrEmpty import de.jeanlucmakiola.floret.reminders.reminderOverrideFor import kotlinx.coroutines.launch @@ -89,6 +94,9 @@ internal fun NotificationsScreen( var showAllDayReminderTime by remember { mutableStateOf(false) } var showSnooze by remember { mutableStateOf(false) } var overrideDialog by remember { mutableStateOf(null) } + var alertStylePicker by remember { mutableStateOf(null) } + var alertSoundPicker by remember { mutableStateOf(null) } + val canFullScreen = rememberCanUseFullScreenIntent() var calendarSectionExpanded by remember { mutableStateOf(false) } var expandedCalendars by remember { mutableStateOf(emptySet()) } val scope = rememberCoroutineScope() @@ -217,6 +225,10 @@ internal fun NotificationsScreen( notifying = notifying, onSetNotifying = { viewModel.setCalendarRemindersEnabled(calendar.id, it) }, onOpenSound = { openCalendarSound(calendar.id) }, + canFullScreen = canFullScreen, + onEditAlertStyle = { alertStylePicker = calendar.id }, + onEditAlertSound = { alertSoundPicker = calendar.id }, + onAllowFullScreen = { context.openFullScreenIntentSettings() }, onOpenSpecialDates = onOpenSpecialDates, onEditOverride = { overrideDialog = it }, ) @@ -227,6 +239,51 @@ internal fun NotificationsScreen( } } + alertStylePicker?.let { calendarId -> + val current = state.reminderAlertStyles.alertStyleFor(calendarId) + OptionPicker( + title = stringResource(R.string.settings_alert_style), + options = listOf(ReminderAlertStyle.Notification, ReminderAlertStyle.FullScreen), + selected = if (current.isFullScreen) ReminderAlertStyle.FullScreen else ReminderAlertStyle.Notification, + label = { alertStyleLabel(it) }, + summary = { + stringResource( + if (it.isFullScreen) { + R.string.settings_alert_style_full_screen_summary + } else { + R.string.settings_alert_style_notification_summary + }, + ) + }, + onSelect = { picked -> + if (picked.isFullScreen != current.isFullScreen) { + viewModel.setCalendarAlertStyle(calendarId, picked) + // Guide the user straight to the switch Android 14+ may hold back. + if (picked.isFullScreen && !canFullScreen) context.openFullScreenIntentSettings() + } + }, + onDismiss = { alertStylePicker = null }, + ) + } + alertSoundPicker?.let { calendarId -> + OptionPicker( + title = stringResource(R.string.settings_alert_sound), + options = listOf(ReminderAlertStyle.FullScreen, ReminderAlertStyle.FullScreenRing), + selected = state.reminderAlertStyles.alertStyleFor(calendarId), + label = { alertSoundLabel(it) }, + summary = { + stringResource( + if (it == ReminderAlertStyle.FullScreenRing) { + R.string.settings_alert_sound_ring_summary + } else { + R.string.settings_alert_sound_once_summary + }, + ) + }, + onSelect = { viewModel.setCalendarAlertStyle(calendarId, it) }, + onDismiss = { alertSoundPicker = null }, + ) + } if (showSnooze) { DurationPicker( title = stringResource(R.string.settings_snooze_duration), @@ -318,11 +375,17 @@ private fun CalendarNotificationRows( notifying: Boolean, onSetNotifying: (Boolean) -> Unit, onOpenSound: () -> Unit, + canFullScreen: Boolean, + onEditAlertStyle: () -> Unit, + onEditAlertSound: () -> Unit, + onAllowFullScreen: () -> Unit, onOpenSpecialDates: () -> Unit, onEditOverride: (OverrideTarget) -> Unit, ) { val managed = calendar.id in state.managedCalendarIds val takesOverrides = calendar.canModifyContents && !managed + val alertStyle = state.reminderAlertStyles.alertStyleFor(calendar.id) + val needsPermission = alertStyle.isFullScreen && !canFullScreen Column { GroupedSwitchRow( title = stringResource(R.string.settings_calendar_notifications), @@ -334,10 +397,34 @@ private fun CalendarNotificationRows( GroupedRow( title = stringResource(R.string.settings_notification_sound), summary = stringResource(R.string.settings_calendar_notification_sound_hint), - position = if (managed || takesOverrides) Position.Middle else Position.Bottom, + position = Position.Middle, trailing = { ExternalLinkIcon() }, onClick = onOpenSound, ) + val lastAlertRow = !(managed || takesOverrides) + GroupedRow( + title = stringResource(R.string.settings_alert_style), + summary = alertStyleLabel(alertStyle), + position = if (lastAlertRow && !alertStyle.isFullScreen) Position.Bottom else Position.Middle, + onClick = onEditAlertStyle, + ) + if (alertStyle.isFullScreen) { + GroupedRow( + title = stringResource(R.string.settings_alert_sound), + summary = alertSoundLabel(alertStyle), + position = if (lastAlertRow && !needsPermission) Position.Bottom else Position.Middle, + onClick = onEditAlertSound, + ) + } + if (needsPermission) { + GroupedRow( + title = stringResource(R.string.settings_full_screen_permission), + summary = stringResource(R.string.settings_full_screen_permission_hint), + position = if (lastAlertRow) Position.Bottom else Position.Middle, + trailing = { ExternalLinkIcon() }, + onClick = onAllowFullScreen, + ) + } if (managed) { GroupedRow( title = stringResource(R.string.settings_default_reminder_allday), @@ -399,6 +486,20 @@ private fun calendarOverrideSummary( is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes) } +@Composable +private fun alertStyleLabel(style: ReminderAlertStyle): String = stringResource( + if (style.isFullScreen) R.string.settings_alert_style_full_screen else R.string.settings_alert_style_notification, +) + +@Composable +private fun alertSoundLabel(style: ReminderAlertStyle): String = stringResource( + if (style == ReminderAlertStyle.FullScreenRing) R.string.settings_alert_sound_ring else R.string.settings_alert_sound_once, +) + +/** Whether Android lets full-screen alerts open, re-read on every `ON_RESUME` like [rememberBatteryOptimizationExempt]. */ +@Composable +private fun rememberCanUseFullScreenIntent(): Boolean = rememberOnResume { it.canUseFullScreenIntent() } + /** Snooze delays offered for the notification "Snooze" action, in minutes. */ private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60) @@ -407,20 +508,22 @@ private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60) * `ON_RESUME` so a change made in system settings shows up at once. */ @Composable -private fun rememberBatteryOptimizationExempt(): Boolean { +private fun rememberBatteryOptimizationExempt(): Boolean = rememberOnResume(::isIgnoringBatteryOptimizations) + +/** [read], re-read on every `ON_RESUME` so a change made in system settings shows up at once. */ +@Composable +private fun rememberOnResume(read: (Context) -> Boolean): Boolean { val context = LocalContext.current - var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) } + var value by remember { mutableStateOf(read(context)) } val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - exempt = isIgnoringBatteryOptimizations(context) - } + if (event == Lifecycle.Event.ON_RESUME) value = read(context) } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } - return exempt + return value } private fun isIgnoringBatteryOptimizations(context: Context): Boolean { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 43a8edce..af27bcf5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.settings +import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs @@ -102,6 +103,8 @@ data class SettingsUiState( val reminderCalendars: List = emptyList(), /** Calendars whose reminders Calendula doesn't post (#272). */ val mutedReminderCalendarIds: Set = emptySet(), + /** Per-calendar alert styles (#171); absent means a plain notification. */ + val reminderAlertStyles: Map = emptyMap(), /** * Whether the event-colour picker is offered on calendars that publish no * colour palette (the colour may then not survive their next sync). diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 5dae73ee..278b6e45 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -37,6 +37,7 @@ import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.reminders.ReminderChannels import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType @@ -131,6 +132,7 @@ class SettingsViewModel @Inject constructor( writableCalendars = calendars.filter { it.canModifyContents }, reminderCalendars = calendars, mutedReminderCalendarIds = this[SettingsKeys.mutedReminderCalendarIds], + reminderAlertStyles = this[SettingsKeys.reminderAlertStyles], allowColorOnUnsupportedCalendars = this[SettingsKeys.allowColorOnUnsupportedCalendars], managedCalendarIds = specialDatesCalendars.values.toSet(), ) @@ -370,6 +372,8 @@ class SettingsViewModel @Inject constructor( } } + fun setCalendarAlertStyle(calendarId: Long, style: ReminderAlertStyle) = save { setCalendarAlertStyle(calendarId, style) } + /** [calendarId]'s reminder channel, created if needed so its system settings can open (#340). */ suspend fun reminderChannelFor(calendarId: Long): String? = onChannels { channelFor(calendarId) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc92c56e..3e0ffec5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -376,6 +376,8 @@ Not now Snooze Dismiss + Ringing reminders + Full-screen reminders that ring until you dismiss them. Calendula plays their sound itself. Today Tomorrow @@ -738,6 +740,18 @@ Notification sound Sound, vibration and pop-ups for each calendar, in system settings Sound, vibration and pop-ups for this calendar, in system settings + Alert style + Notification + A notification that pops up and waits in the shade + Full-screen alert + Fills the screen, even on the lock screen, until you dismiss or snooze it + Full-screen sound + Play sound once + This calendar\'s notification sound, once + Ring until dismissed + Your alarm sound and vibration, like an alarm + Allow full-screen alerts + Android blocks Calendula\'s full-screen alerts, so they only pop up as notifications. Tap to allow them. Default (%1$s) Reliable delivery Android may delay reminders to save battery. Exempt Calendula so they arrive on time. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 1e113689..beb7a63e 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -10,6 +10,7 @@ import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType +import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS @@ -614,6 +615,18 @@ class SettingsPrefsTest { assertThat(prefs.mutedReminderCalendarIds.first()).containsExactly(9L) } + @Test + fun `calendar alert styles default to notifications and persist per calendar`( + @TempDir tempDir: Path, + ) = runTest { + val prefs = testSettings(tempDir) + assertThat(prefs.reminderAlertStyles.first()).isEmpty() + prefs.setCalendarAlertStyle(7L, ReminderAlertStyle.FullScreenRing) + prefs.setCalendarAlertStyle(9L, ReminderAlertStyle.FullScreen) + prefs.setCalendarAlertStyle(9L, ReminderAlertStyle.Notification) + assertThat(prefs.reminderAlertStyles.first()).containsExactly(7L, ReminderAlertStyle.FullScreenRing) + } + @Test fun `garbage per-calendar duration entries are dropped, the rest survive`( @TempDir tempDir: Path, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderAlertStyleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderAlertStyleTest.kt new file mode 100644 index 00000000..17e9b0ec --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderAlertStyleTest.kt @@ -0,0 +1,38 @@ +package de.jeanlucmakiola.calendula.domain.reminders + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class ReminderAlertStyleTest { + + @Test + fun `styles round-trip through their stored form`() { + val styles = mapOf(9L to ReminderAlertStyle.FullScreenRing, 3L to ReminderAlertStyle.FullScreen) + + assertThat(parseAlertStyles(styles.toStoredAlertStyles())).isEqualTo(styles) + } + + @Test + fun `plain notifications are not stored`() { + val stored = mapOf(1L to ReminderAlertStyle.Notification, 2L to ReminderAlertStyle.FullScreen).toStoredAlertStyles() + + assertThat(stored).isEqualTo("2=full_screen") + } + + @Test + fun `garbled entries are dropped, the rest survive`() { + assertThat(parseAlertStyles("x=full_screen;4=loud;5;6=full_screen_ring")) + .containsExactly(6L, ReminderAlertStyle.FullScreenRing) + assertThat(parseAlertStyles(null)).isEmpty() + } + + @Test + fun `a calendar without a style uses notifications`() { + val styles = mapOf(1L to ReminderAlertStyle.FullScreen) + + assertThat(styles.alertStyleFor(1L)).isEqualTo(ReminderAlertStyle.FullScreen) + assertThat(styles.alertStyleFor(2L)).isEqualTo(ReminderAlertStyle.Notification) + assertThat(ReminderAlertStyle.Notification.isFullScreen).isFalse() + assertThat(ReminderAlertStyle.FullScreenRing.isFullScreen).isTrue() + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4822bd47..861ec3dc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -409,6 +409,24 @@ off switch (#272): the scan plans nothing for those calendars (so they arm no wake-ups; toggling it re-scans), and `ReminderNotifier.post` checks it again for snoozes. +**Full-screen alerts (#171).** Settings → Notifications gives each calendar an +alert style (`SettingsPrefs.reminderAlertStyles`): a plain notification, a +full-screen alert that sounds once, or one that rings until handled. Only +`ReminderNotifier.post` reads it, and `ReminderChannels.channelFor(id, style)` +picks the channel. "Sound once" stays on the calendar's own channel, so its +sound is still the one chosen in system settings, and adds a full-screen intent +to `ReminderAlertActivity`. "Ring" starts `ReminderRingService`, which goes +foreground on the shared silent `reminders_ring` channel and loops the alarm +sound with vibration through floret-kit's `ring` module — the same player +Clockula's alarms use. One reminder rings at a time; a newer one takes over and +hands the older back to the shade quietly, as does a ring left unanswered for +ten minutes. Dismiss and Snooze, from the screen, the notification or a swipe, +all go through `ReminderActionReceiver`, so the ring stops and the screen +closes (`FullScreenAlerts`) however the reminder was handled. When Android 14+ +withholds `USE_FULL_SCREEN_INTENT`, the intent is left off and the alert is a +heads-up; Settings shows a row leading to the system switch. When Android +refuses the ring service, the reminder falls back to sounding once. + Deliberately absent: a fallback to the provider's `EVENT_REMINDER` broadcast. Keeping both would double-post wherever the provider works, and Etar's way out — a latch that disables its own scheduling once a real broadcast arrives — cannot diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index adfd8a34..ae5cda07 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -96,9 +96,7 @@ The settings screen contains links to the source code, the licence, the issue tr - `USE_EXACT_ALARM`, `SCHEDULE_EXACT_ALARM`: deliver reminders at the exact time, including after snoozing. - `RECEIVE_BOOT_COMPLETED`: re-register pending reminders after a restart. - `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`: only to open the system dialog for the “Reliable delivery” setting. -- `WAKE_LOCK`, `FOREGROUND_SERVICE`, `ACCESS_NETWORK_STATE`: required by the Android system component used for scheduled background work (WorkManager). - -`ACCESS_NETWORK_STATE` allows reading *whether* a network connection exists. It does **not** permit using one. Without `INTERNET`, no connection is possible. +- `USE_FULL_SCREEN_INTENT`, `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SYSTEM_EXEMPTED`, `WAKE_LOCK`, `VIBRATE`: only for calendars you set to full-screen alerts — showing the alert over the lock screen and, with “Ring until dismissed”, ringing and vibrating until you dismiss or snooze it. ## 7. Distribution channels diff --git a/floret-kit b/floret-kit index 730c9eb2..86b8e608 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 730c9eb25a6bf2f93e3388bf132769d6dd1afd91 +Subproject commit 86b8e608623d6e254d74c6ea7362e04cc26a9adf