feat(#171): full-screen reminder alerts per calendar

This commit is contained in:
makiolaj committed 2026-11-13 17:00:46 +01:00
1 parent fa999f8cf0
commit c52c6c54cc
22 files changed
+1005 -46

No files matched your search

+1
View File
@@ -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)
+31 -3
View File
@@ -41,10 +41,20 @@
-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- WorkManager declares these for network constraints and foreground workers, neither of which the app uses (#448). -->
<!-- WorkManager declares this for network constraints, which the app doesn't use (#448). -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
<uses-permission android:name="android.permission.WAKE_LOCK" tools:node="remove" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" tools:node="remove" />
<!--
Full-screen reminder alerts (#171), opt-in per calendar. The alert screen
opens over the lock screen through a full-screen intent; "Ring until
dismissed" rings from a foreground service (systemExempted, covered by
the exact-alarm permission above) with its own wake lock and vibration.
-->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
returns null and the calendar manager's per-account "manage" button can't
@@ -322,6 +332,24 @@
android:name=".data.reminders.ReminderActionReceiver"
android:exported="false" />
<!-- The full-screen reminder alert (#171): over the lock screen, out of
recents, in a task of its own so it never brings the app with it. -->
<activity
android:name=".ui.reminders.ReminderAlertActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:showWhenLocked="true"
android:taskAffinity=""
android:turnScreenOn="true" />
<!-- Rings a "Ring until dismissed" reminder (#171). systemExempted: an
app holding an exact-alarm permission continuing an alarm. -->
<service
android:name=".data.reminders.ReminderRingService"
android:exported="false"
android:foregroundServiceType="systemExempted" />
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
<activity
android:name=".widget.config.WidgetConfigActivity"
@@ -5,6 +5,9 @@ import androidx.datastore.preferences.core.Preferences
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.domain.reminders.parseAlertStyles
import de.jeanlucmakiola.calendula.domain.reminders.toStoredAlertStyles
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
@@ -167,6 +170,7 @@ class SettingsPrefs @Inject constructor(
val perCalendarEventDuration: Flow<Map<Long, Int>> = prefs.flow(SettingsKeys.perCalendarEventDuration)
val remindersEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.remindersEnabled)
val mutedReminderCalendarIds: Flow<Set<Long>> = prefs.flow(SettingsKeys.mutedReminderCalendarIds)
val reminderAlertStyles: Flow<Map<Long, ReminderAlertStyle>> = prefs.flow(SettingsKeys.reminderAlertStyles)
val allowColorOnUnsupportedCalendars: Flow<Boolean> =
prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars)
val defaultReminderMinutes: Flow<List<Int>> = 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<Set<Long>> = idSetPref("muted_reminder_calendar_ids")
/** Per-calendar alert styles (#171); a calendar absent from the map uses plain notifications. */
val reminderAlertStyles: Pref<Map<Long, ReminderAlertStyle>> =
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
@@ -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<Long>(
extraBufferCapacity = 8,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** The [ReminderAlert.key]s of reminders as they are dismissed or snoozed. */
val handled: SharedFlow<Long> = handledKeys.asSharedFlow()
fun markHandled(alert: ReminderAlert) {
handledKeys.tryEmit(alert.key)
}
}
@@ -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),
@@ -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"
@@ -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
@@ -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.
@@ -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))
}
}
}
@@ -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<Long, ReminderAlertStyle>.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<Long, ReminderAlertStyle> =
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<Long, ReminderAlertStyle>.toStoredAlertStyles(): String =
entries.filter { it.value != ReminderAlertStyle.Notification }
.sortedBy { it.key }
.joinToString(";") { (id, style) -> "$id=${style.storageValue}" }
@@ -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>(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)
}
}
@@ -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() }
}
}
@@ -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
}
@@ -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<OverrideTarget?>(null) }
var alertStylePicker by remember { mutableStateOf<Long?>(null) }
var alertSoundPicker by remember { mutableStateOf<Long?>(null) }
val canFullScreen = rememberCanUseFullScreenIntent()
var calendarSectionExpanded by remember { mutableStateOf(false) }
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
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 {
@@ -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<CalendarSource> = emptyList(),
/** Calendars whose reminders Calendula doesn't post (#272). */
val mutedReminderCalendarIds: Set<Long> = emptySet(),
/** Per-calendar alert styles (#171); absent means a plain notification. */
val reminderAlertStyles: Map<Long, ReminderAlertStyle> = emptyMap(),
/**
* Whether the event-colour picker is offered on calendars that publish no
* colour palette (the colour may then not survive their next sync).
@@ -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) }
+14
View File
@@ -376,6 +376,8 @@
<string name="reminder_onboarding_skip_button">Not now</string>
<string name="reminder_action_snooze">Snooze</string>
<string name="reminder_action_dismiss">Dismiss</string>
<string name="reminder_ring_channel_name">Ringing reminders</string>
<string name="reminder_ring_channel_description">Full-screen reminders that ring until you dismiss them. Calendula plays their sound itself.</string>
<!-- Day context prefix in a reminder for an event on another day (v2.15.0) -->
<string name="reminder_day_today">Today</string>
<string name="reminder_day_tomorrow">Tomorrow</string>
@@ -738,6 +740,18 @@
<string name="settings_notification_sound">Notification sound</string>
<string name="settings_notification_sound_hint">Sound, vibration and pop-ups for each calendar, in system settings</string>
<string name="settings_calendar_notification_sound_hint">Sound, vibration and pop-ups for this calendar, in system settings</string>
<string name="settings_alert_style">Alert style</string>
<string name="settings_alert_style_notification">Notification</string>
<string name="settings_alert_style_notification_summary">A notification that pops up and waits in the shade</string>
<string name="settings_alert_style_full_screen">Full-screen alert</string>
<string name="settings_alert_style_full_screen_summary">Fills the screen, even on the lock screen, until you dismiss or snooze it</string>
<string name="settings_alert_sound">Full-screen sound</string>
<string name="settings_alert_sound_once">Play sound once</string>
<string name="settings_alert_sound_once_summary">This calendar\'s notification sound, once</string>
<string name="settings_alert_sound_ring">Ring until dismissed</string>
<string name="settings_alert_sound_ring_summary">Your alarm sound and vibration, like an alarm</string>
<string name="settings_full_screen_permission">Allow full-screen alerts</string>
<string name="settings_full_screen_permission_hint">Android blocks Calendula\'s full-screen alerts, so they only pop up as notifications. Tap to allow them.</string>
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
<string name="settings_reliable_delivery">Reliable delivery</string>
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
@@ -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,
@@ -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()
}
}
+18
View File
@@ -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
+1 -3
View File
@@ -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