Compare commits

...
Author SHA1 Message Date
makiolaj 7361e8d98e fix(#171): review round 6 - mark the redeliverable start done on takeover 2026-11-13 18:18:04 +01:00
makiolaj 9178828b41 fix(#171): review round 5 - hand back on a hidden ring, counted pending starts, keep the first start's redelivery 2026-11-13 18:13:27 +01:00
makiolaj 0b6cdf992e fix(#171): review round 4 - targeted ring stops, verdict before any sound, stop when the ring notification is hidden, no re-ring of a handed-back reminder 2026-11-13 18:06:17 +01:00
makiolaj 014d31f9b9 fix(#171): review round 3 - re-check settings in the ring, follow calls, anchored timeout, close the screen on every stop 2026-11-13 17:56:11 +01:00
makiolaj 4287862f47 chore(#171): floret-kit ring fixes 2026-11-13 17:45:25 +01:00
makiolaj 6cf6a6fdbf fix(#171): review round 2 - ordered ring commands, hand-back that outlives the service, in-call and blocked-group checks, scrolling alert screen 2026-11-13 17:33:26 +01:00
makiolaj d2b6637f72 chore(#171): floret-kit ring review fixes 2026-11-13 17:20:13 +01:00
makiolaj 0b223f53fc fix(#171): review round 1 - ring ids, hand-back off the main thread, blocked channels, closing the alert screen 2026-11-13 17:12:03 +01:00
makiolaj c52c6c54cc feat(#171): full-screen reminder alerts per calendar 2026-11-13 17:00:46 +01:00
makiolaj fa999f8cf0 fix(#145): review round 3 — rescan on mute, serialise channel writes, never drop a reminder for its channel 2026-11-13 16:37:11 +01:00
makiolaj 515c7c3d26 fix(#145): review round 2 — guard empty snapshots and the post path, skip muted calendars in the scan 2026-11-13 16:29:34 +01:00
makiolaj 9b5880b719 fix(#145): review round 1 — vibration carry-over, cheaper channel sync, shared account grouping 2026-11-13 16:23:08 +01:00
makiolaj 994fc1026f feat(#145, #272, #340): per-calendar reminder channels, in-app mute and sound deep-links 2026-11-13 16:14:54 +01:00
26 changed files with 2037 additions and 127 deletions

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)
+22 -3
View File
@@ -41,10 +41,15 @@
-->
<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 and their ring (#171), opt-in per calendar. -->
<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 +327,20 @@
android:name=".data.reminders.ReminderActionReceiver"
android:exported="false" />
<!-- The full-screen reminder alert (#171), in a task of its own. -->
<activity
android:name=".ui.reminders.ReminderAlertActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:taskAffinity="" />
<!-- Rings a "Ring until dismissed" reminder (#171). -->
<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"
@@ -81,7 +81,7 @@ class CalendarPrefs @Inject constructor(
}
/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
private fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
internal fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
{ stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() },
{ ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") },
)
@@ -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
@@ -166,6 +169,8 @@ class SettingsPrefs @Inject constructor(
val defaultEventDurationMinutes: Flow<Int> = prefs.flow(SettingsKeys.defaultEventDurationMinutes)
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)
@@ -273,6 +278,14 @@ class SettingsPrefs @Inject constructor(
suspend fun setRemindersEnabled(enabled: Boolean) = prefs.set(SettingsKeys.remindersEnabled, enabled)
suspend fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
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)
@@ -594,6 +607,16 @@ object SettingsKeys {
*/
val remindersEnabled: Pref<Boolean> = booleanPref("reminders_enabled", true)
/**
* Calendars whose reminders Calendula doesn't post (#272), independent of
* `Calendars.VISIBLE`: the events still show, only the notifications stop.
*/
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,85 @@
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 java.util.concurrent.ConcurrentHashMap
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() {
/** Written only by [ReminderRingService], on the main thread, for as long as it rings. */
@Volatile
var ringing: ReminderAlert? = null
internal set
/** Rings handed to [ReminderRingService] that have not reached it yet, with how many starts each. */
private val pending = ConcurrentHashMap<Long, Pair<ReminderAlert, Int>>()
/** Set when the service is stopped from outside but its ring should go back to the shade. */
@Volatile
internal var handBackOnDestroy = false
internal fun addPending(alert: ReminderAlert) {
pending.compute(alert.key) { _, entry -> alert to ((entry?.second ?: 0) + 1) }
}
internal fun removePending(alert: ReminderAlert) {
pending.computeIfPresent(alert.key) { _, entry -> if (entry.second <= 1) null else entry.first to entry.second - 1 }
}
internal fun isPending(alert: ReminderAlert): Boolean = pending.containsKey(alert.key)
internal val anyPending: Boolean get() = pending.isNotEmpty()
/**
* Whether a ring going or on its way [match]es, so a stop is worth sending.
* Pending first: the service sets [ringing] before it lets a start go.
*/
fun mayRing(match: RingMatch): Boolean =
pending.values.any { match.matches(it.first) } || ringing?.let(match::matches) == true
private val handledKeys = MutableSharedFlow<Long>(
extraBufferCapacity = 8,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** The [ReminderAlert.key]s of reminders as they are dismissed, snoozed or handed back. */
val handled: SharedFlow<Long> = handledKeys.asSharedFlow()
fun markHandled(alert: ReminderAlert) {
handledKeys.tryEmit(alert.key)
}
}
/** What becomes of a ring once its settings are re-read. */
enum class RingVerdict { Ring, HandBack, Drop }
/** Which ring a stop is for. */
sealed interface RingMatch {
fun matches(alert: ReminderAlert): Boolean
data class Key(val key: Long) : RingMatch {
override fun matches(alert: ReminderAlert) = alert.key == key
}
data class Occurrence(val occurrenceKey: Long) : RingMatch {
override fun matches(alert: ReminderAlert) = alert.occurrenceKey == occurrenceKey
}
data class Calendar(val calendarId: Long) : RingMatch {
override fun matches(alert: ReminderAlert) = alert.calendarId == calendarId
}
data object Any : RingMatch {
override fun matches(alert: ReminderAlert) = true
}
}
@@ -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),
@@ -0,0 +1,133 @@
package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.domain.CalendarSource
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"
/** The notification channel [calendarId]'s reminders post on. */
fun reminderChannelId(calendarId: Long): String = CHANNEL_PREFIX + calendarId
/** Whether [channelId] is one of the per-calendar reminder channels. */
fun isReminderChannelId(channelId: String): Boolean = channelId.startsWith(CHANNEL_PREFIX)
/** Whether [groupId] is one of the per-account reminder channel groups. */
fun isReminderGroupId(groupId: String): Boolean = groupId.startsWith(GROUP_PREFIX)
/**
* One account's channel group, labelled as the calendar manager labels it. The
* app's own device calendars share a single group ([isLocal]); [ambiguous]
* marks a [label] another group shows too, so it should name the source app.
*/
data class ReminderChannelGroupSpec(
val id: String,
val label: String,
val accountType: String,
val isLocal: Boolean,
val ambiguous: Boolean,
)
data class ReminderChannelSpec(
val id: String,
val calendarId: Long,
val name: String,
/** Null only for a channel created before its calendar could be looked up. */
val groupId: String?,
/**
* Whether a sync creates it up front. A calendar that syncs no events never
* fires, so its channel only appears once something posts on it.
*/
val upFront: Boolean = true,
)
/** What a channel already looks like in the system, as far as the plan compares it. */
data class ExistingReminderChannel(val id: String, val name: String, val description: String?, val groupId: String?)
data class ReminderChannelPlan(
val groups: List<ReminderChannelGroupSpec>,
val channels: List<ReminderChannelSpec>,
/**
* The [channels] a sync writes: missing up-front ones, and existing ones
* whose name or (localized) description changed, or that still lack a group.
*/
val channelsToWrite: List<ReminderChannelSpec>,
/** The [groups] holding a channel that exists or is about to be written. */
val groupsInUse: List<ReminderChannelGroupSpec>,
val staleChannelIds: List<String>,
val staleGroupIds: List<String>,
)
/**
* What the reminder channels should look like for [calendars]: one channel per
* calendar inside a group per account, which of them need writing, and the
* reminder channels and groups in [existing] / [existingGroupIds] no calendar
* claims any more. Ids outside the reminder namespace (the backup channel, the
* legacy channel) are never touched.
*/
fun planReminderChannels(
calendars: List<CalendarSource>,
existing: Collection<ExistingReminderChannel>,
existingGroupIds: Collection<String>,
unnamed: String,
description: String,
): ReminderChannelPlan {
val (local, synced) = calendars.partition { it.isLocal }
val groups = buildList {
if (local.isNotEmpty()) {
add(ReminderChannelGroupSpec(LOCAL_GROUP_ID, "", local.first().accountType, isLocal = true, ambiguous = false))
}
synced.groupByAccount().forEach { group ->
add(
ReminderChannelGroupSpec(
id = accountGroupId(group.key.type, group.key.name),
label = group.label,
accountType = group.accountType,
isLocal = false,
ambiguous = group.ambiguous,
),
)
}
}
val channels = calendars.map { calendar ->
ReminderChannelSpec(
id = reminderChannelId(calendar.id),
calendarId = calendar.id,
name = calendar.displayName.ifBlank { unnamed },
groupId = if (calendar.isLocal) LOCAL_GROUP_ID else accountGroupId(calendar.accountType, calendar.accountName),
upFront = calendar.isLocal || calendar.syncsEvents,
)
}
val existingById = existing.associateBy { it.id }
val wantedChannels = channels.mapTo(HashSet()) { it.id }
val wantedGroups = groups.mapTo(HashSet()) { it.id }
val toWrite = channels.filter { spec ->
val current = existingById[spec.id]
if (current == null) {
spec.upFront
} else {
current.name != spec.name || current.description != description ||
(current.groupId == null && spec.groupId != null)
}
}
val usedGroupIds = channels
.filter { it.id in existingById || it in toWrite }
.mapNotNullTo(HashSet()) { it.groupId }
return ReminderChannelPlan(
groups = groups,
channels = channels,
channelsToWrite = toWrite,
groupsInUse = groups.filter { it.id in usedGroupIds },
staleChannelIds = existingById.keys.filter { isReminderChannelId(it) && it !in wantedChannels },
staleGroupIds = existingGroupIds.filter { isReminderGroupId(it) && it !in wantedGroups },
)
}
private fun accountGroupId(type: String, name: String): String = "$GROUP_PREFIX$type/$name"
@@ -0,0 +1,243 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.content.Context
import android.util.Log
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
import javax.inject.Singleton
/**
* The reminder notification channels: one per calendar, grouped by account
* (#145), created up front so each calendar can be muted or given its own sound
* in the system settings before it ever fires.
*
* A channel the user has touched keeps their settings; [sync] only creates the
* missing ones, renames changed ones, and deletes those whose calendar is gone.
*/
@Singleton
class ReminderChannels @Inject constructor(
@ApplicationContext private val context: Context,
private val calendarDataSource: CalendarDataSource,
) {
private val manager: NotificationManager
get() = context.getSystemService(NotificationManager::class.java)
/** What the last [sync] saw, so an unchanged provider costs no channel writes. */
@Volatile
private var lastSync: Any? = null
/**
* Bring the channels in line with the provider's calendars. An empty
* snapshot changes nothing: that is a provider hiccup far more often than
* every calendar gone at once.
*/
@Synchronized
fun sync() {
val calendars = calendarDataSource.calendars()
if (calendars.isEmpty()) return
val manager = manager
val existing = manager.notificationChannels
val existingIds = existing.mapTo(HashSet()) { it.id }
if (syncKey(calendars, existingIds) == lastSync && LEGACY_REMINDER_CHANNEL_ID !in existingIds) return
val groups = manager.notificationChannelGroups
val plan = plan(calendars, existing, groups.map { it.id })
plan.staleChannelIds.forEach(manager::deleteNotificationChannel)
plan.staleGroupIds.forEach(manager::deleteNotificationChannelGroup)
val groupNames = groups.associate { it.id to it.name?.toString() }
write(
groups = plan.groupsInUse.filter { groupNames[it.id] != groupLabel(it) },
channels = plan.channelsToWrite,
existing = existing,
)
if (RING_CHANNEL_ID in existingIds) writeRingChannel()
val ids = existingIds - plan.staleChannelIds.toSet() + plan.channelsToWrite.map { it.id }
lastSync = syncKey(calendars, if (retireLegacyChannel()) ids - LEGACY_REMINDER_CHANNEL_ID else ids)
}
private fun syncKey(calendars: List<CalendarSource>, channelIds: Set<String>): Any = listOf(
calendars.map { listOf(it.id, it.displayName, it.accountName, it.accountType, it.isLocal, it.syncsEvents) },
Locale.getDefault(),
channelIds,
)
/**
* The channel to post [calendarId]'s reminder on, created first if a sync
* 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. A ringing [style] posts on the
* shared ring channel instead, once the calendar is known to exist.
*/
@Synchronized
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) writeRingChannel()
return RING_CHANNEL_ID
}
/** Creates the ring channel, or renames it after a language change. */
private fun writeRingChannel() {
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
},
)
}
/**
* Whether a ring may sound for [calendarId]: neither its own channel, its
* account's group nor the ring channel is blocked, since the service plays
* the sound itself.
*/
fun canRing(calendarId: Long): Boolean {
val manager = manager
return listOf(reminderChannelId(calendarId), RING_CHANNEL_ID).none { id ->
val channel = manager.getNotificationChannel(id) ?: return@none false
channel.importance == NotificationManager.IMPORTANCE_NONE ||
channel.group?.let { manager.getNotificationChannelGroup(it)?.isBlocked } == true
}
}
private fun calendarChannelFor(calendarId: Long): String? {
val id = reminderChannelId(calendarId)
val manager = manager
if (manager.getNotificationChannel(id) != null) return id
val existing = manager.notificationChannels
val calendars = try {
calendarDataSource.calendars()
} catch (e: Exception) {
Log.w(TAG, "Could not look up calendar $calendarId for its channel", e)
emptyList()
}
if (calendars.isEmpty()) {
val fallback = ReminderChannelSpec(id, calendarId, context.getString(R.string.reminder_channel_name), null)
write(emptyList(), listOf(fallback), existing)
return id
}
val plan = plan(calendars, existing, emptyList())
val spec = plan.channels.firstOrNull { it.calendarId == calendarId } ?: return null
val knownGroups = manager.notificationChannelGroups.mapTo(HashSet()) { it.id }
write(
groups = plan.groups.filter { it.id == spec.groupId && it.id !in knownGroups },
channels = listOf(spec),
existing = existing,
)
return id
}
private fun plan(
calendars: List<CalendarSource>,
existing: List<NotificationChannel>,
existingGroupIds: List<String>,
): ReminderChannelPlan = planReminderChannels(
calendars = calendars,
existing = existing.map { ExistingReminderChannel(it.id, it.name.toString(), it.description, it.group) },
existingGroupIds = existingGroupIds,
unnamed = context.getString(R.string.calendar_unnamed),
description = context.getString(R.string.reminder_channel_description),
)
private fun write(
groups: List<ReminderChannelGroupSpec>,
channels: List<ReminderChannelSpec>,
existing: List<NotificationChannel>,
) {
val manager = manager
if (groups.isNotEmpty()) {
manager.createNotificationChannelGroups(groups.map { NotificationChannelGroup(it.id, groupLabel(it)) })
}
if (channels.isEmpty()) return
val byId = existing.associateBy { it.id }
val template = byId[LEGACY_REMINDER_CHANNEL_ID]
// Only a channel's first creation sets its behaviour; later calls just
// rename it, so an existing one passes its own importance back.
manager.createNotificationChannels(
channels.map { spec ->
val current = byId[spec.id]
NotificationChannel(
spec.id,
spec.name,
current?.importance ?: template?.importance ?: NotificationManager.IMPORTANCE_HIGH,
).apply {
description = context.getString(R.string.reminder_channel_description)
spec.groupId?.let { group = it }
if (current == null && template != null) inheritFrom(template)
}
},
)
}
/**
* Carry the old single channel's sound, vibration and lock-screen choices
* over to a new per-calendar one, so an upgrade keeps how reminders sounded.
*/
private fun NotificationChannel.inheritFrom(template: NotificationChannel) {
setSound(template.sound, template.audioAttributes)
// The pattern setter also flips vibration on or off, so it goes first.
vibrationPattern = template.vibrationPattern
enableVibration(template.shouldVibrate())
enableLights(template.shouldShowLights())
lightColor = template.lightColor
lockscreenVisibility = template.lockscreenVisibility
setShowBadge(template.canShowBadge())
// Only honoured with Do Not Disturb access, so usually left to the user.
setBypassDnd(template.canBypassDnd())
}
/**
* Delete the pre-#145 channel once the per-calendar ones carry its settings,
* but not while a reminder posted on it is still on screen — deleting the
* channel would take that notification with it. Returns whether it is gone.
*/
private fun retireLegacyChannel(): Boolean {
val manager = manager
if (manager.getNotificationChannel(LEGACY_REMINDER_CHANNEL_ID) == null) return true
val showing = runCatching {
manager.activeNotifications.any { it.notification.channelId == LEGACY_REMINDER_CHANNEL_ID }
}.getOrElse { e ->
Log.w(TAG, "Could not read active notifications", e)
true
}
if (!showing) manager.deleteNotificationChannel(LEGACY_REMINDER_CHANNEL_ID)
return !showing
}
private fun groupLabel(group: ReminderChannelGroupSpec): String {
if (group.isLocal) return context.getString(R.string.calendars_local_header)
val name = group.label.ifBlank { context.getString(R.string.calendar_unnamed) }
if (!group.ambiguous) return name
return context.getString(R.string.calendars_account_from_source, name, sourceAppLabel(context, group.accountType))
}
private companion object {
const val TAG = "ReminderChannels"
}
}
@@ -1,8 +1,7 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.os.Build
@@ -18,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
@@ -29,8 +32,9 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Posts one notification per occurrence on a dedicated channel. Tapping opens
* the event's detail screen.
* Posts one notification per occurrence on its calendar's channel
* ([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
@@ -43,6 +47,8 @@ class ReminderNotifier @Inject constructor(
private val settingsPrefs: SettingsPrefs,
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. */
@@ -53,20 +59,85 @@ class ReminderNotifier @Inject constructor(
}
/**
* The single choke point for "this calendar is switched off", covering the
* two paths that reach [post] around the scan's own filter: a snooze armed
* before the switch-off, and a read-only install whose switch lives in
* [CalendarPrefs].
* The single choke point for "this calendar posts no reminders": switched
* off (also covering a snooze armed before the switch-off, and a read-only
* install whose switch lives in [CalendarPrefs]), or its notifications
* turned off in Calendula's own settings (#272).
*/
private suspend fun isSilenced(calendarId: Long): Boolean =
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
calendarId in settingsPrefs.mutedReminderCalendarIds.first() ||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
calendarDataSource.isCalendarVisible(calendarId) == false
/** Post [alert], unless its calendar is switched off. */
suspend fun post(alert: ReminderAlert) {
/**
* Post [alert] in its calendar's alert style (#171), unless the calendar is
* silenced or gone. [ring] false posts a ringing style as a full-screen
* alert that sounds once instead; [quiet] posts a plain notification that
* makes no sound. Both are how a ring hands a reminder back to the shade.
*/
suspend fun post(alert: ReminderAlert, quiet: Boolean = false, ring: Boolean = true) {
if (isSilenced(alert.calendarId)) return
ensureChannel()
val style = when {
quiet -> ReminderAlertStyle.Notification
else -> effectiveStyle(alert).let {
if (it == ReminderAlertStyle.FullScreenRing && !ring) ReminderAlertStyle.FullScreen else it
}
}
// Rung already and handed back to the shade: a catch-up post leaves it there.
if (style == ReminderAlertStyle.FullScreenRing && (isShowing(alert) || alerts.isPending(alert))) return
val channelId = channelFor(alert, style) ?: return
val title = displayTitle(alert)
val text = contentText(alert)
if (style == ReminderAlertStyle.FullScreenRing) {
if (ReminderRingService.start(context, alerts, alert, build(alert, channelId, title, text, style, quiet = false))) {
// 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.
stopRing(RingMatch.Occurrence(alert.occurrenceKey))
val fallback = channelFor(alert, ReminderAlertStyle.FullScreen) ?: return
notify(alert, build(alert, fallback, title, text, ReminderAlertStyle.FullScreen, quiet = false))
return
}
// A fresh reminder replaces a ring for its occurrence from before the style changed.
if (ring) stopRing(RingMatch.Occurrence(alert.occurrenceKey))
notify(alert, build(alert, channelId, title, text, style, quiet))
}
/** Whether a ring for [alert] should go on, now that settings may have changed since it started. */
suspend fun ringVerdict(alert: ReminderAlert): RingVerdict = when {
!settingsPrefs.remindersEnabled.first() || !canPost() || isSilenced(alert.calendarId) -> RingVerdict.Drop
effectiveStyle(alert) != ReminderAlertStyle.FullScreenRing -> RingVerdict.HandBack
else -> RingVerdict.Ring
}
/** Whether a live ring for [alert] may go on: its notification can still be seen. */
fun canStillRing(alert: ReminderAlert): Boolean = canPost() && channels.canRing(alert.calendarId)
/** The calendar's style, short of ringing where the user blocked its calendar, group or the ring channel. */
private suspend fun effectiveStyle(alert: ReminderAlert): ReminderAlertStyle {
val chosen = settingsPrefs.reminderAlertStyles.first().alertStyleFor(alert.calendarId)
return if (chosen == ReminderAlertStyle.FullScreenRing && !channels.canRing(alert.calendarId)) {
ReminderAlertStyle.FullScreen
} else {
chosen
}
}
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()
@@ -74,7 +145,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,
@@ -87,19 +158,35 @@ 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, CHANNEL_ID)
}
/**
* The reminder's notification in [style]. A ringing one belongs to
* [ReminderRingService]: ongoing and opening the alert screen. A full-screen
* one adds the alert screen as a full-screen intent where Android allows
* it, degrading to a heads-up where it does not, and is dismissed by a
* swipe, so its screen closes too.
*/
private fun build(
alert: ReminderAlert,
channelId: String,
title: String,
text: String,
style: ReminderAlertStyle,
quiet: Boolean,
): Notification {
val ringing = style == ReminderAlertStyle.FullScreenRing
val fullScreen = style.isFullScreen
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),
@@ -110,7 +197,18 @@ class ReminderNotifier @Inject constructor(
context.getString(R.string.reminder_action_dismiss),
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
)
.build()
if (fullScreen) builder.setDeleteIntent(actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS))
if (ringing) {
builder.setOngoing(true).setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
} 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 {
@@ -131,10 +229,25 @@ 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)
stopRing(RingMatch.Key(alert.key))
alerts.markHandled(alert)
}
/** Stop the ring if it is for a reminder [match]es; [handBack] keeps it in the shade, quietly. */
fun stopRing(match: RingMatch, handBack: Boolean = false) {
if (alerts.mayRing(match)) ReminderRingService.stop(context, alerts, match, handBack)
}
/** 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? {
@@ -153,7 +266,7 @@ class ReminderNotifier @Inject constructor(
private fun cancelLegacy(alert: ReminderAlert) {
val manager = NotificationManagerCompat.from(context)
manager.activeNotifications
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == CHANNEL_ID }
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == LEGACY_REMINDER_CHANNEL_ID }
.filter { !it.notification.extras.containsKey(EXTRA_REMINDER_KEY) }
.filter { it.notification.`when` == alert.beginMillis }
.filter { it.notification.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString() == displayTitle(alert) }
@@ -171,6 +284,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.
@@ -181,21 +301,8 @@ class ReminderNotifier @Inject constructor(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** Channel creation is idempotent; re-running refreshes the localized name. */
private fun ensureChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.reminder_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = context.getString(R.string.reminder_channel_description)
}
NotificationManagerCompat.from(context).createNotificationChannel(channel)
}
private companion object {
const val TAG = "ReminderNotifier"
const val CHANNEL_ID = "reminders"
// One id, distinct tags: the tag (occurrence) already keys the
// notification, so a fixed id keeps cancellation/replacement simple.
@@ -0,0 +1,345 @@
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.app.NotificationManagerCompat
import androidx.core.content.IntentCompat
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
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.isInCall
import de.jeanlucmakiola.floret.ring.startRingForeground
import kotlinx.coroutines.CancellationException
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 kotlinx.coroutines.withContext
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
* goes back to the shade, silently, unless it was for the same occurrence.
* Starts and stops both arrive as commands, so they are handled on the main
* thread in the order they were sent.
*/
@AndroidEntryPoint
class ReminderRingService : Service() {
@Inject lateinit var alerts: FullScreenAlerts
@Inject lateinit var notifier: ReminderNotifier
@Inject @ApplicationScope
lateinit var appScope: CoroutineScope
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
private var notificationId = RING_NOTIFICATION_ID
private var lastStartId: Int? = null
/** The start the system would redeliver, so a takeover can mark it done. */
private var ringStartId: Int? = null
/** Whether the ring's players were ever built, so a stop-only service builds none. */
private var rang = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
lastStartId = startId
if (intent?.action == ACTION_STOP) {
stop(intent)
return START_NOT_STICKY
}
val alert = intent?.let(ReminderActionReceiver::alertFrom)
if (intent == null || alert == null) {
stopIfIdle()
return START_NOT_STICKY
}
try {
return startRing(intent, alert, startId)
} finally {
// Only now, once alerts.ringing says whether it rings, so a stop sent meanwhile is never skipped.
if (flags and START_FLAG_REDELIVERY == 0) alerts.removePending(alert)
}
}
private fun startRing(intent: Intent, alert: ReminderAlert, startId: Int): Int {
val notification = IntentCompat.getParcelableExtra(intent, EXTRA_NOTIFICATION, Notification::class.java)
if (notification == null) {
stopIfIdle()
return START_NOT_STICKY
}
val previous = alerts.ringing
// A different reminder posts under the other id, so it is a new
// notification whose full-screen intent opens; an update would not.
val replacing = previous != null && previous.key != alert.key
val shown = notificationId
if (replacing) notificationId = if (shown == RING_NOTIFICATION_ID) OTHER_RING_NOTIFICATION_ID else RING_NOTIFICATION_ID
val foreground = runCatching { startRingForeground(notificationId, notification) }
if (foreground.isFailure) {
// A restart after a process death may be refused a foreground start:
// the reminder still shows full-screen, sounding once.
Log.w(TAG, "Could not ring for event ${alert.eventId}", foreground.exceptionOrNull())
notificationId = shown
handBack(alert, quiet = false)
stopIfIdle()
return START_NOT_STICKY
}
if (replacing) NotificationManagerCompat.from(this).cancel(shown)
// A duplicate start for this very ring leaves the original in charge of redelivery.
if (previous?.key == alert.key) return START_NOT_STICKY
// Only the newest start is redelivered after a process death; the older
// ones are done, so a handled reminder never rings again.
ringStartId?.let { stopSelfResult(it) }
ringStartId = startId
alerts.ringing = alert
if (previous != null) {
// Its screen closes either way; another occurrence goes back to the shade.
if (previous.occurrenceKey != alert.occurrenceKey) handBack(previous) else alerts.markHandled(previous)
}
val since = intent.getLongExtra(EXTRA_SINCE, System.currentTimeMillis())
ring(alert, remaining = since + RING_TIMEOUT_MILLIS - System.currentTimeMillis())
return START_REDELIVER_INTENT
}
override fun onDestroy() {
if (rang) releaseRing()
// Stopped from outside: its screen must not stay up for a ring that is gone.
alerts.ringing?.let { if (alerts.handBackOnDestroy) handBack(it) else alerts.markHandled(it) }
alerts.handBackOnDestroy = false
alerts.ringing = null
scope.cancel()
super.onDestroy()
}
/** Rings for what is left of the ring's time, so a ring restarted after a process death does not begin again. */
private fun ring(alert: ReminderAlert, remaining: Long) {
releaseRing()
rang = true
wakeLock.acquire()
timeout = scope.launch {
delay(remaining.coerceAtLeast(0L))
giveUp()
}
ringing = scope.launch {
// Settings changed while this ring was on its way apply before it makes a sound.
val verdict = try {
withContext(Dispatchers.IO) { notifier.ringVerdict(alert) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Could not re-check the ring for event ${alert.eventId}", e)
RingVerdict.Ring
}
if (verdict != RingVerdict.Ring) {
// Never heard yet, so it alerts once in its new style.
if (verdict == RingVerdict.HandBack) handBack(alert, quiet = false)
end()
return@launch
}
sound(alert)
}
}
/**
* Tone and vibration, paused for a phone call and back after it, until the
* user hides the ring's notification, which would leave no way to stop it.
*/
private suspend fun sound(alert: ReminderAlert) {
var sounding = false
var tick = 0
while (true) {
if (tick++ % VISIBILITY_CHECK_TICKS == 0 && !notifier.canStillRing(alert)) {
handBack(alert)
end()
return
}
val inCall = isInCall()
if (inCall && sounding) {
tones.stop()
vibrator.stop()
sounding = false
} else if (!inCall && !sounding) {
vibrator.start()
tones.play(scope, ringSources())
sounding = true
}
delay(CALL_CHECK_MILLIS)
}
}
private fun stop(intent: Intent) {
val current = alerts.ringing
if (current != null && matchFrom(intent).matches(current)) {
if (intent.getBooleanExtra(EXTRA_HAND_BACK, false)) handBack(current)
end()
} else {
stopIfIdle()
}
}
/** Rung long enough: back to the shade quietly. */
private fun giveUp() {
timeout = null
alerts.ringing?.let(::handBack)
end()
}
/** Ends the ring, closes its screen, and stops the service unless a newer command is on its way. */
private fun end() {
alerts.ringing?.let(alerts::markHandled)
releaseRing()
alerts.ringing = null
stopForeground(STOP_FOREGROUND_REMOVE)
stopIfIdle()
}
/** Declines while a newer command is still on its way. */
private fun stopIfIdle() {
if (alerts.ringing == null) lastStartId?.let { stopSelfResult(it) }
}
/**
* [alert] back in the shade, in the app's scope so a service stopped
* meanwhile cannot cancel it, and its alert screen closed. A newer reminder
* for the occurrence that took its place meanwhile is left alone.
*/
private fun handBack(alert: ReminderAlert, quiet: Boolean = true) {
alerts.markHandled(alert)
appScope.launch {
try {
if (!notifier.isSuperseded(alert)) notifier.post(alert, quiet = quiet, ring = false)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Could not hand the reminder of event ${alert.eventId} back", e)
}
}
}
private fun releaseRing() {
ringing?.cancel()
ringing = null
timeout?.cancel()
timeout = null
tones.stop()
vibrator.stop()
wakeLock.release()
}
/** The device's alarm sound; "None", or none opening, leaves the vibration. */
private fun ringSources() = listOfNotNull(
RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_ALARM),
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM),
)
companion object {
private const val TAG = "ReminderRingService"
private const val ACTION_RING = "de.jeanlucmakiola.calendula.reminders.RING"
private const val ACTION_STOP = "de.jeanlucmakiola.calendula.reminders.RING_STOP"
private const val EXTRA_NOTIFICATION = "notification"
private const val EXTRA_SINCE = "since"
private const val CALL_CHECK_MILLIS = 1_000L
/** How many call checks between re-reading whether the ring's notification can still be seen. */
private const val VISIBILITY_CHECK_TICKS = 5
private const val EXTRA_MATCH_KIND = "match_kind"
private const val EXTRA_MATCH_VALUE = "match_value"
private const val EXTRA_HAND_BACK = "hand_back"
private const val WAKE_LOCK_TAG = "calendula:reminder-ring"
/** Clear of the reminders' tagged id 1 and the backup notice's 2. */
private const val RING_NOTIFICATION_ID = 171
private const val OTHER_RING_NOTIFICATION_ID = 172
/** 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, alerts: FullScreenAlerts, alert: ReminderAlert, notification: Notification): Boolean {
val intent = Intent(context, ReminderRingService::class.java)
.setAction(ACTION_RING)
.putAlert(alert)
.putExtra(EXTRA_NOTIFICATION, notification)
.putExtra(EXTRA_SINCE, System.currentTimeMillis())
alerts.addPending(alert)
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)
alerts.removePending(alert)
false
}
}
/**
* Stops the ring if it is for a reminder [match]es, in order with the
* starts sent before it. [handBack] puts it back in the shade quietly.
* A command rather than `stopService`, which would also drop a start
* still on its way; sent while the ring's foreground service keeps the
* app allowed to.
*/
fun stop(context: Context, alerts: FullScreenAlerts, match: RingMatch, handBack: Boolean = false) {
val (kind, value) = when (match) {
is RingMatch.Key -> 0 to match.key
is RingMatch.Occurrence -> 1 to match.occurrenceKey
is RingMatch.Calendar -> 2 to match.calendarId
RingMatch.Any -> 3 to 0L
}
val intent = Intent(context, ReminderRingService::class.java)
.setAction(ACTION_STOP)
.putExtra(EXTRA_MATCH_KIND, kind)
.putExtra(EXTRA_MATCH_VALUE, value)
.putExtra(EXTRA_HAND_BACK, handBack)
try {
context.startService(intent)
} catch (e: Exception) {
Log.w(TAG, "Could not stop the ring", e)
// Refused from the background: nothing must ring on regardless,
// unless a start is still on its way, which this would crash.
if (!alerts.anyPending) {
alerts.handBackOnDestroy = handBack
context.stopService(Intent(context, ReminderRingService::class.java))
}
}
}
private fun matchFrom(intent: Intent): RingMatch {
val value = intent.getLongExtra(EXTRA_MATCH_VALUE, 0L)
return when (intent.getIntExtra(EXTRA_MATCH_KIND, 3)) {
0 -> RingMatch.Key(value)
1 -> RingMatch.Occurrence(value)
2 -> RingMatch.Calendar(value)
else -> RingMatch.Any
}
}
}
}
@@ -43,6 +43,7 @@ class ReminderScanner @Inject constructor(
private val source: ReminderInstanceSource,
private val calendarDataSource: CalendarDataSource,
private val notifier: ReminderNotifier,
private val channels: ReminderChannels,
private val snoozes: ReminderSnoozeScheduler,
private val alarms: ReminderAlarmScheduler,
private val state: ReminderStatePrefs,
@@ -85,10 +86,14 @@ class ReminderScanner @Inject constructor(
return
}
syncChannels()
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
// Reach into the past too: an all-day "at time of event" encodes to a
// negative offset, and a catch-up pass needs the occurrences it missed.
// Calendars muted in-app (#272) plan nothing, so they arm no wake-ups.
val muted = settingsPrefs.mutedReminderCalendarIds.first()
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
.filter { it.calendarId !in muted }
val planned = planReminders(
instances = occurrences,
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId })
@@ -117,6 +122,19 @@ class ReminderScanner @Inject constructor(
alarms.scheduleScan(schedule.nextAlarmMillis)
}
/**
* A provider change re-scans, so a calendar added, renamed or removed keeps
* its channel in step here. Skipped while reminders are off, when the
* channels have nothing to do. A failure never costs the reminders.
*/
private fun syncChannels() {
try {
channels.sync()
} catch (e: Exception) {
Log.w(TAG, "Reminder channel sync failed", e)
}
}
private fun hasReadCalendar(): Boolean = context.hasPermission(Manifest.permission.READ_CALENDAR)
/**
@@ -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}" }
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
@@ -72,11 +73,13 @@ fun accountGroupTitle(group: CalendarAccountGroup): String {
@Composable
fun sourceAppName(accountType: String): String {
val context = LocalContext.current
return remember(accountType) {
val pm = context.packageManager
val packages = sourceAppPackages(context, accountType)
packages.firstNotNullOfOrNull { pkg ->
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
} ?: accountType
}
return remember(accountType) { sourceAppLabel(context, accountType) }
}
/** [sourceAppName] outside composition, e.g. for notification channel groups. */
fun sourceAppLabel(context: Context, accountType: String): String {
val pm = context.packageManager
return sourceAppPackages(context, accountType).firstNotNullOfOrNull { pkg ->
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
} ?: accountType
}
@@ -0,0 +1,181 @@
package de.jeanlucmakiola.calendula.ui.reminders
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.WindowManager
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.core.net.toUri
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.data.reminders.ReminderRingService
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.delay
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, for a ringing one, from its heads-up.
* 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
private var screenOn: 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
// A ring still going, this one's or another's, keeps the screen up as long as it rings.
val ringing = alerts.ringing != null
keepScreenOn(if (ringing) ReminderRingService.RING_TIMEOUT_MILLIS else ONCE_SCREEN_ON_MILLIS)
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,
)
}
}
/** An alert nobody answers lets the display sleep again; it stays on the lock screen. */
private fun keepScreenOn(millis: Long) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
screenOn?.cancel()
screenOn = lifecycleScope.launch {
delay(millis)
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
private fun act(action: String) {
val current = alert ?: return
sendBroadcast(ReminderActionReceiver.intent(this, action, current))
finish()
}
companion object {
private const val TAG = "ReminderAlertActivity"
private const val ONCE_SCREEN_ON_MILLIS = 60_000L
fun intent(context: Context, alert: ReminderAlert): Intent =
Intent(context, ReminderAlertActivity::class.java)
.setData("calendula://reminder-alert/${alert.key}".toUri())
.putAlert(alert)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION)
}
}
@@ -0,0 +1,195 @@
package de.jeanlucmakiola.calendula.ui.reminders
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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,
) {
// Centred, but scrolls rather than pushing the buttons off a short landscape screen.
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.heightIn(min = maxHeight),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
state.calendarName?.let {
Text(
text = it,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.size(24.dp))
}
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.size(24.dp))
Row(
modifier = Modifier.widthIn(max = 480.dp).fillMaxWidth(),
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
}
@@ -14,23 +14,25 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.LifecycleResumeEffect
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
@@ -39,17 +41,22 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.calendula.ui.common.durationLabel
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.android.openNotificationSettings
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
import kotlinx.datetime.LocalTime
/**
@@ -84,8 +91,15 @@ 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()
val openCalendarSound: (Long) -> Unit = { calendarId ->
scope.launch { context.openNotificationSettings(viewModel.reminderChannelFor(calendarId)) }
}
CollapsingScaffold(
title = stringResource(R.string.settings_section_notifications),
@@ -100,6 +114,18 @@ internal fun NotificationsScreen(
summary = stringResource(R.string.settings_reminders_hint),
position = Position.Top,
)
GroupedRow(
title = stringResource(R.string.settings_notification_sound),
summary = stringResource(R.string.settings_notification_sound_hint),
position = Position.Middle,
trailing = { ExternalLinkIcon() },
onClick = {
scope.launch {
viewModel.syncReminderChannels()
context.openNotificationSettings()
}
},
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = reminderChoiceLabel(state.defaultReminderMinutes),
@@ -149,10 +175,10 @@ internal fun NotificationsScreen(
onClick = { showSnooze = true },
)
// Per-calendar overrides, folded behind one header. Each writable
// calendar may keep, drop or replace the global default, separately for
// timed and all-day events.
if (state.writableCalendars.isNotEmpty()) {
// Per-calendar settings, folded behind one header: every switched-on
// calendar gets its notification switch and sound (#145, #272); the ones
// you can edit also keep, drop or replace the default reminders.
if (state.reminderCalendars.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_calendar_reminders_title),
@@ -167,30 +193,13 @@ internal fun NotificationsScreen(
exit = collapseExit(),
) {
Column {
state.writableCalendars.forEach { calendar ->
state.reminderCalendars.forEach { calendar ->
Spacer(Modifier.height(16.dp))
// Special-dates calendars own their reminders in their
// own section — link there instead.
if (calendar.id in state.managedCalendarIds) {
GroupedRow(
title = calendar.displayName.orUnnamed(),
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
position = Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = onOpenSpecialDates,
)
return@forEach
}
val expanded = calendar.id in expandedCalendars
val notifying = calendar.id !in state.mutedReminderCalendarIds
GroupedRow(
title = calendar.displayName.orUnnamed(),
summary = if (notifying) null else stringResource(R.string.settings_calendar_notifications_off),
position = if (expanded) Position.Top else Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = { ExpandIcon(expanded) },
@@ -207,22 +216,19 @@ internal fun NotificationsScreen(
enter = expandEnter(),
exit = collapseExit(),
) {
Column {
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
position = Position.Middle,
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
)
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
position = Position.Bottom,
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
)
}
CalendarNotificationRows(
calendar = calendar,
state = state,
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 },
)
}
}
}
@@ -230,6 +236,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),
@@ -309,6 +360,116 @@ internal fun NotificationsScreen(
}
}
/**
* One calendar's expanded rows: its notification switch and channel deep-link
* for every calendar, then the default-reminder overrides where it takes them —
* or, for a special-dates calendar, a link to the section that owns them.
*/
@Composable
private fun CalendarNotificationRows(
calendar: CalendarSource,
state: SettingsUiState,
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),
checked = notifying,
onCheckedChange = onSetNotifying,
summary = stringResource(R.string.settings_calendar_notifications_hint),
position = Position.Middle,
)
GroupedRow(
title = stringResource(R.string.settings_notification_sound),
summary = stringResource(
if (alertStyle == ReminderAlertStyle.FullScreenRing) {
R.string.settings_calendar_notification_sound_ring_hint
} else {
R.string.settings_calendar_notification_sound_hint
},
),
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),
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
position = Position.Bottom,
trailing = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = onOpenSpecialDates,
)
} else if (takesOverrides) {
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
position = Position.Middle,
onClick = { onEditOverride(OverrideTarget(calendar.id, isAllDay = false)) },
)
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
position = Position.Bottom,
onClick = { onEditOverride(OverrideTarget(calendar.id, isAllDay = true)) },
)
}
}
}
@Composable
private fun ExternalLinkIcon() {
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
@@ -328,6 +489,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)
@@ -336,20 +511,18 @@ 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)) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
exempt = isIgnoringBatteryOptimizations(context)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
var value by remember { mutableStateOf(read(context)) }
LifecycleResumeEffect(context) {
value = read(context)
onPauseOrDispose {}
}
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
@@ -96,8 +97,14 @@ data class SettingsUiState(
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Writable calendars, shown as per-calendar reminder-override rows. */
/** Switched-on calendars you can edit: the per-calendar duration and reminder-override rows. */
val writableCalendars: List<CalendarSource> = emptyList(),
/** Switched-on calendars, each with its own notification switch and channel (#145, #272). */
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).
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.datastore.preferences.core.Preferences
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -33,8 +34,12 @@ import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.reminders.ReminderChannels
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
import de.jeanlucmakiola.calendula.data.reminders.RingMatch
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
@@ -45,6 +50,7 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -67,6 +73,8 @@ class SettingsViewModel @Inject constructor(
specialDatesSpec: SpecialDatesCalendarSpec,
private val launcherNameManager: LauncherNameManager,
private val reminderScanner: ReminderScanner,
private val reminderChannels: ReminderChannels,
private val reminderNotifier: ReminderNotifier,
@IoDispatcher private val io: CoroutineDispatcher,
@ApplicationContext private val appContext: Context,
) : ViewModel() {
@@ -74,16 +82,15 @@ class SettingsViewModel @Inject constructor(
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
/**
* Writable calendars that are switched on — the only ones that take a
* per-calendar reminder override, since a switched-off one plans no
* reminders at all (#75).
* Calendars that are switched on — the only ones that plan reminders at
* all (#75), so the only ones with notification settings.
*/
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents && it.isVisibleInSystem } }
private val visibleCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.isVisibleInSystem } }
.catch { emit(emptyList()) }
val state: StateFlow<SettingsUiState> =
combine(prefs.data, writableCalendars) { stored, calendars -> stored.toUiState(calendars) }
combine(prefs.data, visibleCalendars) { stored, calendars -> stored.toUiState(calendars) }
.stateWhileSubscribed(
scope = viewModelScope,
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
@@ -125,7 +132,10 @@ class SettingsViewModel @Inject constructor(
snoozeMinutes = this[SettingsKeys.snoozeMinutes],
perCalendarReminderOverride = this[SettingsKeys.perCalendarReminderOverride],
perCalendarAllDayReminderOverride = this[SettingsKeys.perCalendarAllDayReminderOverride],
writableCalendars = calendars,
writableCalendars = calendars.filter { it.canModifyContents },
reminderCalendars = calendars,
mutedReminderCalendarIds = this[SettingsKeys.mutedReminderCalendarIds],
reminderAlertStyles = this[SettingsKeys.reminderAlertStyles],
allowColorOnUnsupportedCalendars = this[SettingsKeys.allowColorOnUnsupportedCalendars],
managedCalendarIds = specialDatesCalendars.values.toSet(),
)
@@ -353,10 +363,49 @@ class SettingsViewModel @Inject constructor(
fun setRemindersEnabled(enabled: Boolean) {
viewModelScope.launch {
prefs.setRemindersEnabled(enabled)
if (!enabled) reminderNotifier.stopRing(RingMatch.Any)
reminderScanner.scan()
}
}
/** Re-plans, since a muted calendar armed no wake-up for its next reminder. */
fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
viewModelScope.launch {
prefs.setCalendarRemindersEnabled(calendarId, enabled)
if (!enabled) reminderNotifier.stopRing(RingMatch.Calendar(calendarId))
reminderScanner.scan()
}
}
fun setCalendarAlertStyle(calendarId: Long, style: ReminderAlertStyle) {
viewModelScope.launch {
prefs.setCalendarAlertStyle(calendarId, style)
// A ring for it goes quiet but stays in the shade.
if (style != ReminderAlertStyle.FullScreenRing) {
reminderNotifier.stopRing(RingMatch.Calendar(calendarId), handBack = true)
}
}
}
/** [calendarId]'s reminder channel, created if needed so its system settings can open (#340). */
suspend fun reminderChannelFor(calendarId: Long): String? = onChannels { channelFor(calendarId) }
/** Create the per-calendar channels before showing the app's channel list, even with reminders off. */
suspend fun syncReminderChannels() {
onChannels { sync() }
}
private suspend fun <T> onChannels(block: ReminderChannels.() -> T): T? = withContext(io) {
try {
reminderChannels.block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("SettingsViewModel", "Reminder channel update failed", e)
null
}
}
fun setAutofocusEventTitle(enabled: Boolean) = save { setAutofocusEventTitle(enabled) }
fun setDefaultEventDuration(minutes: Int) = save { setDefaultEventDurationMinutes(minutes) }
+22 -1
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>
@@ -731,7 +733,26 @@
<string name="reminder_custom_amount">Amount</string>
<string name="reminder_custom_set">Set</string>
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
<string name="settings_calendar_reminders_hint">Turn notifications off or pick a sound per calendar, and override the default reminder for calendars you can edit.</string>
<string name="settings_calendar_notifications">Notifications</string>
<string name="settings_calendar_notifications_hint">Notify at this calendar\'s reminder times</string>
<string name="settings_calendar_notifications_off">Notifications off</string>
<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, also in Do Not Disturb when alarms may sound</string>
<string name="settings_calendar_notification_sound_ring_hint">Ringing reminders use your alarm sound. This sets the sound of the ones handed back to the shade.</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
@@ -601,6 +602,31 @@ class SettingsPrefsTest {
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(9L, 30)
}
@Test
fun `calendar notifications default on and toggle per calendar`(
@TempDir tempDir: Path,
) = runTest {
val prefs = testSettings(tempDir)
assertThat(prefs.mutedReminderCalendarIds.first()).isEmpty()
prefs.setCalendarRemindersEnabled(7L, false)
prefs.setCalendarRemindersEnabled(9L, false)
assertThat(prefs.mutedReminderCalendarIds.first()).containsExactly(7L, 9L)
prefs.setCalendarRemindersEnabled(7L, true)
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,133 @@
package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.CalendarSource
import org.junit.jupiter.api.Test
class ReminderChannelPlanTest {
private fun calendar(id: Long, account: String, type: String, local: Boolean = false) = CalendarSource(
id = id,
displayName = "Cal $id",
accountName = account,
accountType = type,
color = 0,
isVisibleInSystem = true,
isLocal = local,
)
@Test
fun `one channel per calendar, grouped by account`() {
val plan = planReminderChannels(
calendars = listOf(
calendar(1, "me@example.org", "com.google"),
calendar(2, "me@example.org", "com.google"),
calendar(3, "dav", "bitfire.at.davdroid"),
),
existing = emptyList(),
existingGroupIds = emptyList(),
unnamed = "Unnamed",
description = DESC,
)
assertThat(plan.groups).hasSize(2)
assertThat(plan.channels.map { it.id })
.containsExactly(reminderChannelId(1), reminderChannelId(2), reminderChannelId(3))
assertThat(plan.channels[0].groupId).isEqualTo(plan.channels[1].groupId)
assertThat(plan.channels[2].groupId).isNotEqualTo(plan.channels[0].groupId)
assertThat(plan.staleChannelIds).isEmpty()
}
@Test
fun `device calendars share one group`() {
val plan = planReminderChannels(
calendars = listOf(
calendar(1, "Calendula", "LOCAL", local = true),
calendar(2, "Other", "LOCAL", local = true),
),
existing = emptyList(),
existingGroupIds = emptyList(),
unnamed = "Unnamed",
description = DESC,
)
assertThat(plan.groups).hasSize(1)
assertThat(plan.groups.single().isLocal).isTrue()
}
@Test
fun `same account name under two types is ambiguous`() {
val plan = planReminderChannels(
calendars = listOf(
calendar(1, "me@example.org", "com.google"),
calendar(2, "me@example.org", "bitfire.at.davdroid"),
calendar(3, "work", "bitfire.at.davdroid"),
),
existing = emptyList(),
existingGroupIds = emptyList(),
unnamed = "Unnamed",
description = DESC,
)
assertThat(plan.groups.filter { it.ambiguous }.map { it.accountType })
.containsExactly("com.google", "bitfire.at.davdroid")
assertThat(plan.groups.single { it.label == "work" }.ambiguous).isFalse()
}
@Test
fun `stale reminder channels and groups go, foreign ones stay`() {
val current = calendar(1, "me@example.org", "com.google")
fun plan(calendars: List<CalendarSource>, existing: List<String> = emptyList(), groups: List<String> = emptyList()) =
planReminderChannels(calendars, existing.map { ExistingReminderChannel(it, "Cal ${it.last()}", DESC, null) }, groups, "Unnamed", DESC)
val keptGroup = plan(listOf(current)).groups.single().id
val goneGroup = plan(listOf(calendar(9, "old", "com.google"))).groups.single().id
val result = plan(
calendars = listOf(current),
existing = listOf(reminderChannelId(1), reminderChannelId(9), LEGACY_REMINDER_CHANNEL_ID, "backup"),
groups = listOf(keptGroup, goneGroup, "other"),
)
assertThat(result.staleChannelIds).containsExactly(reminderChannelId(9))
assertThat(result.staleGroupIds).containsExactly(goneGroup)
}
@Test
fun `only missing or renamed channels are written`() {
val plan = planReminderChannels(
calendars = listOf(
calendar(1, "a", "com.google"),
calendar(2, "a", "com.google"),
calendar(3, "a", "com.google").copy(displayName = ""),
calendar(4, "a", "com.google"),
),
existing = listOf(
ExistingReminderChannel(reminderChannelId(1), "Cal 1", DESC, "g"),
ExistingReminderChannel(reminderChannelId(4), "Cal 4", "Old description", "g"),
ExistingReminderChannel(reminderChannelId(2), "Old name", DESC, "g"),
),
existingGroupIds = emptyList(),
unnamed = "Unnamed",
description = DESC,
)
assertThat(plan.channelsToWrite.map { it.calendarId }).containsExactly(2L, 3L, 4L)
assertThat(plan.channelsToWrite.single { it.calendarId == 3L }.name).isEqualTo("Unnamed")
}
@Test
fun `non-syncing calendars keep a channel but get none up front`() {
val quiet = calendar(2, "a", "com.google").copy(syncsEvents = false)
val fresh = planReminderChannels(listOf(quiet), emptyList(), emptyList(), "Unnamed", DESC)
assertThat(fresh.channelsToWrite).isEmpty()
assertThat(fresh.groupsInUse).isEmpty()
val posted = planReminderChannels(
calendars = listOf(quiet),
existing = listOf(ExistingReminderChannel(reminderChannelId(2), "Cal 2", DESC, null)),
existingGroupIds = emptyList(),
unnamed = "Unnamed",
description = DESC,
)
assertThat(posted.staleChannelIds).isEmpty()
assertThat(posted.channelsToWrite.map { it.calendarId }).containsExactly(2L)
assertThat(posted.groupsInUse).hasSize(1)
}
private companion object {
const val DESC = "Reminders"
}
}
@@ -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()
}
}
+43
View File
@@ -391,6 +391,49 @@ gate also covers a snooze re-shown from our own alarm after its calendar was
switched off. The drawer's filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a
separate in-app declutter that never touches reminders.
**A channel per calendar (#145).** `ReminderChannels` keeps one notification
channel per calendar, grouped by account (one that syncs no events only gets
its channel once something posts on it), so a calendar can be
muted or given its own sound in the system settings before it ever fires. Every
scan with reminders on syncs them (skipped when nothing changed): missing
channels are created, renamed ones updated, and channels of deleted calendars
removed — but never on an empty snapshot, which is a provider hiccup more often
than every calendar gone. Switched-off calendars keep theirs, so toggling one
never cancels a showing reminder or loses its settings. `post` creates a missing
channel itself, so a reminder never waits on a sync. A new channel copies the sound, vibration and
importance of the pre-#145 single `reminders` channel, which is deleted once
nothing showing still sits on it. Settings → Notifications only deep-links into
the system's channel screens (#340); the app never sets a sound itself. Separate
from both, `SettingsPrefs.mutedReminderCalendarIds` is the in-app per-calendar
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, and switching the calendar away from ringing. 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. Starts and stops reach
the service as commands, so a dismiss sent while a newer ring is on its way
applies in order rather than killing it, and the service re-reads the settings
once a ring arrives, so a style change or mute that raced a scan still wins. A
blocked calendar, account group or ring channel never rings, the tone pauses
for a phone call, and a ring restarted after a process death only rings out
what was left of its ten minutes. 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
+2 -3
View File
@@ -96,9 +96,8 @@ 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`, `VIBRATE`: 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.
- `WAKE_LOCK`: keeps the phone awake while a full-screen alert rings, and lets the Android component that runs scheduled background work (WorkManager) finish it.
## 7. Distribution channels