Compare commits

..
16 changed files with 89 additions and 1372 deletions

No files matched your search

+4 -1
View File
@@ -318,13 +318,16 @@ jobs:
# not provide node inside custom job containers, so JavaScript actions
# (checkout, download-artifact) can't run there. The Renovate job gets away
# with a container because its only step is a shell command. Ruby is installed
# the same way sshpass, jq and fdroidserver are in the job above.
# the same way sshpass, jq and fdroidserver are in the job above, from trixie:
# fastlane >= 2.241 needs Ruby 3.2+, and bookworm's apt only has 3.1.
play:
needs: [detect, release]
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
# touch Play, so gate on a real release only.
if: needs.detect.outputs.is_release == 'true'
runs-on: docker
container:
image: node:22-trixie
env:
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
@@ -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. */
internal fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
private 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(",") },
)
@@ -166,9 +166,6 @@ 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 notificationCategoryHintDismissed: Flow<Boolean> = prefs.flow(SettingsKeys.notificationCategoryHintDismissed)
val reminderChannelVersions: Flow<Map<Long, Int>> = prefs.flow(SettingsKeys.reminderChannelVersions)
val allowColorOnUnsupportedCalendars: Flow<Boolean> =
prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars)
val defaultReminderMinutes: Flow<List<Int>> = prefs.flow(SettingsKeys.defaultReminderMinutes)
@@ -276,25 +273,6 @@ class SettingsPrefs @Inject constructor(
suspend fun setRemindersEnabled(enabled: Boolean) = prefs.set(SettingsKeys.remindersEnabled, enabled)
/**
* Reserve a new reminder channel version for each calendar in [floors], above
* both its stored high-water mark and its floor, and return them. Versions
* only grow: the system would restore a deleted channel's old settings if
* its id came back (#518).
*/
suspend fun reserveReminderChannelVersions(floors: Map<Long, Int>): Map<Long, Int> {
val stored = prefs.update(SettingsKeys.reminderChannelVersions) { current ->
current + floors.mapValues { (id, floor) -> maxOf((current[id] ?: 0) + 1, floor) }
}
return floors.keys.associateWith { stored.getValue(it) }
}
suspend fun dismissNotificationCategoryHint() = prefs.set(SettingsKeys.notificationCategoryHintDismissed, true)
suspend fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
prefs.update(SettingsKeys.mutedReminderCalendarIds) { if (enabled) it - calendarId else it + calendarId }
}
suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) =
prefs.set(SettingsKeys.allowColorOnUnsupportedCalendars, enabled)
@@ -616,27 +594,6 @@ 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")
/**
* Whether the one-time hint that some skins (Samsung) start new notification
* categories switched off has been dismissed (#145).
*/
val notificationCategoryHintDismissed: Pref<Boolean> = booleanPref("notification_category_hint_dismissed", false)
/**
* The highest reminder channel version handed out per calendar (#518): a
* sound picked in the app recreates the channel under a new one. The
* channels themselves say which version is current; this only keeps ids
* from being reused. Serialised as `id=version;id=version`.
*/
val reminderChannelVersions: Pref<Map<Long, Int>> =
nullableStringPref("reminder_channel_versions").map(::parseChannelVersions) { it.toStoredVersions() }
/**
* 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
@@ -1020,18 +977,6 @@ private fun parseDurationOverrides(stored: String?): Map<Long, Int> =
calendarId to minutes
}.toMap()
private fun parseChannelVersions(stored: String?): Map<Long, Int> =
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP)
if (parts.size != 2) return@mapNotNull null
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
val version = parts[1].trim().toIntOrNull()?.takeIf { it > 0 } ?: return@mapNotNull null
calendarId to version
}.toMap()
private fun Map<Long, Int>.toStoredVersions(): String? =
if (isEmpty()) null else entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, v) -> "$id$KEY_VALUE_SEP$v" }
private fun Map<Long, Int>.toStoredDurations(): String =
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, minutes) ->
"$id$KEY_VALUE_SEP$minutes"
@@ -1,201 +0,0 @@
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"
private const val CHANNEL_PREFIX = "reminders.calendar."
private const val GROUP_PREFIX = "reminders.account."
private const val LOCAL_GROUP_ID = GROUP_PREFIX + "local"
private const val VERSION_SEPARATOR = ".v"
/**
* The notification channel [calendarId]'s reminders post on. A sound picked in
* the app moves it to a new [version] (#518), since a channel's sound is fixed
* once created; version 0 is the unversioned id #145 introduced.
*/
fun reminderChannelId(calendarId: Long, version: Int = 0): String =
if (version == 0) "$CHANNEL_PREFIX$calendarId" else "$CHANNEL_PREFIX$calendarId$VERSION_SEPARATOR$version"
/** The calendar and version a reminder channel id names, or null for any other id. */
fun parseReminderChannelId(channelId: String): Pair<Long, Int>? {
if (!isReminderChannelId(channelId)) return null
val rest = channelId.removePrefix(CHANNEL_PREFIX)
val calendar = rest.substringBefore(VERSION_SEPARATOR).toLongOrNull() ?: return null
if (VERSION_SEPARATOR !in rest) return calendar to 0
val version = rest.substringAfter(VERSION_SEPARATOR).toIntOrNull()?.takeIf { it > 0 } ?: return null
return calendar to version
}
/**
* Each calendar's current channel version: the highest one that exists, so a
* new version only takes over once it is written. A calendar with none falls
* back to its [highWater] mark — re-creating that id restores its own settings.
*/
fun currentReminderChannelVersions(existingChannelIds: Collection<String>, highWater: Map<Long, Int>): Map<Long, Int> {
val existing = existingChannelIds.mapNotNull(::parseReminderChannelId)
.groupBy({ it.first }, { it.second })
.mapValues { (_, versions) -> versions.max() }
return highWater + existing
}
/** 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>,
/** Stale channels kept for now, because a notification on them is still showing. */
val keptChannelIds: List<String> = emptyList(),
)
/**
* 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,
versions: Map<Long, Int> = emptyMap(),
showingChannelIds: Set<String> = emptySet(),
): 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 existingById = existing.associateBy { it.id }
val channels = calendars.map { calendar ->
ReminderChannelSpec(
id = reminderChannelId(calendar.id, versions[calendar.id] ?: 0),
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 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 }
val (kept, stale) = existingById.keys
.filter { isReminderChannelId(it) && it !in wantedChannels }
.partition { it in showingChannelIds }
val keptGroups = kept.mapNotNullTo(HashSet()) { existingById.getValue(it).groupId }
return ReminderChannelPlan(
groups = groups,
channels = channels,
channelsToWrite = toWrite,
groupsInUse = groups.filter { it.id in usedGroupIds },
staleChannelIds = stale,
staleGroupIds = existingGroupIds.filter { isReminderGroupId(it) && it !in wantedGroups && it !in keptGroups },
keptChannelIds = kept,
)
}
private fun accountGroupId(type: String, name: String): String = "$GROUP_PREFIX$type/$name"
/** A channel as far as its blocked state goes: switched off itself, and the group it sits in. */
data class ReminderChannelState(val blocked: Boolean, val groupId: String?)
/** Reminder notifications the system blocks, while Calendula would post them. */
data class ReminderChannelStatus(
val appBlocked: Boolean = false,
val blockedCalendarIds: Set<Long> = emptySet(),
/** The blocked ones whose whole account group is off, which only the app's own screen can undo. */
val groupBlockedCalendarIds: Set<Long> = emptySet(),
)
/**
* What the system blocks of [calendarIds]' reminders: all of them while the
* app's notifications are off, otherwise those whose channel, or the group it
* sits in, is switched off. A calendar without a channel yet isn't blocked.
*/
fun reminderChannelStatus(
calendarIds: Collection<Long>,
appEnabled: Boolean,
channels: Map<String, ReminderChannelState>,
blockedGroupIds: Set<String>,
versions: Map<Long, Int> = emptyMap(),
): ReminderChannelStatus {
fun channel(calendarId: Long) = channels[reminderChannelId(calendarId, versions[calendarId] ?: 0)]
val groupBlocked = calendarIds.filterTo(HashSet()) { channel(it)?.groupId in blockedGroupIds }
val blocked = if (!appEnabled) {
calendarIds.toSet()
} else {
calendarIds.filterTo(HashSet()) { channel(it)?.blocked == true } + groupBlocked
}
return ReminderChannelStatus(appBlocked = !appEnabled, blockedCalendarIds = blocked, groupBlockedCalendarIds = groupBlocked)
}
@@ -1,353 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.app.NotificationManager.IMPORTANCE_DEFAULT
import android.app.NotificationManager.IMPORTANCE_LOW
import android.app.NotificationManager.IMPORTANCE_MIN
import android.content.Context
import android.media.AudioAttributes
import android.net.Uri
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.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.sourceAppLabel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
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.
* A sound picked in the app ([setSound], #518) recreates the channel under the
* calendar's next version; the old one goes once nothing shows on it.
*/
@Singleton
class ReminderChannels @Inject constructor(
@ApplicationContext private val context: Context,
private val calendarDataSource: CalendarDataSource,
private val settingsPrefs: SettingsPrefs,
) {
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 }
val versions = currentVersions(existingIds).also { knownVersions = it }
if (syncKey(calendars, existingIds, versions) == lastSync && LEGACY_REMINDER_CHANNEL_ID !in existingIds) return
val groups = manager.notificationChannelGroups
val groupIds = groups.map { it.id }
// Active notifications are only read when there is something stale to protect.
val plan = plan(calendars, existing, groupIds, versions).let { draft ->
if (draft.staleChannelIds.isEmpty()) draft else plan(calendars, existing, groupIds, versions, showingChannelIds(draft.staleChannelIds))
}
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,
)
val ids = existingIds - plan.staleChannelIds.toSet() + plan.channelsToWrite.map { it.id }
val legacyGone = retireLegacyChannel()
// A kept channel still has to go once its notification does, so don't cache.
lastSync = if (plan.keptChannelIds.isEmpty()) {
syncKey(calendars, if (legacyGone) ids - LEGACY_REMINDER_CHANNEL_ID else ids, versions)
} else {
null
}
}
private fun syncKey(calendars: List<CalendarSource>, channelIds: Set<String>, versions: Map<Long, Int>): Any = listOf(
calendars.map { listOf(it.id, it.displayName, it.accountName, it.accountType, it.isLocal, it.syncsEvents) },
Locale.getDefault(),
channelIds,
versions,
)
/**
* The current versions last resolved under the lock ([sync], [channelFor]).
* Only [recreate], under the same lock, moves a calendar higher and updates
* it, so a hit whose channel still exists is current.
*/
@Volatile
private var knownVersions: Map<Long, Int> = emptyMap()
private fun currentVersions(existingChannelIds: Collection<String>): Map<Long, Int> =
currentReminderChannelVersions(existingChannelIds, runBlocking { settingsPrefs.reminderChannelVersions.first() })
/** Channels with a notification on screen; on failure all of [candidates] count, so none is deleted. */
private fun showingChannelIds(candidates: Collection<String>): Set<String> = runCatching {
manager.activeNotifications.mapNotNullTo(HashSet()) { it.notification.channelId }
}.getOrElse { e ->
Log.w(TAG, "Could not read active notifications", e)
candidates.toSet()
}
/**
* 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.
*/
@Synchronized
fun channelFor(calendarId: Long): String? {
val manager = manager
knownVersions[calendarId]?.let { version ->
val known = reminderChannelId(calendarId, version)
if (manager.getNotificationChannel(known) != null) return known
}
val existing = manager.notificationChannels
val versions = currentVersions(existing.map { it.id }).also { knownVersions = it }
val id = reminderChannelId(calendarId, versions[calendarId] ?: 0)
if (existing.any { it.id == id }) return id
val calendars = try {
calendarDataSource.calendars()
} catch (e: Exception) {
Log.w(TAG, "Could not look up calendar $calendarId for its channel", e)
emptyList()
}
val fallback = ReminderChannelSpec(id, calendarId, context.getString(R.string.reminder_channel_name), null)
if (calendars.isEmpty()) {
write(emptyList(), listOf(fallback), existing)
return id
}
val plan = plan(calendars, existing, emptyList(), versions)
val spec = plan.channels.firstOrNull { it.calendarId == calendarId } ?: return null
try {
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,
)
} catch (e: RuntimeException) {
Log.w(TAG, "Could not file the channel of calendar $calendarId", e)
write(emptyList(), listOf(fallback), existing)
}
return id
}
/**
* Run [block] with [calendarId]'s channel held: no sound pick or sync can
* move or retire it until [block] returns. Null, without running [block],
* when the calendar is gone or its channel can't be made; [block]'s own
* failures propagate.
*/
@Synchronized
fun <T> withChannel(calendarId: Long, block: (String) -> T): T? {
val channelId = try {
channelFor(calendarId)
} catch (e: Exception) {
Log.w(TAG, "No channel for calendar $calendarId", e)
null
}
return channelId?.let(block)
}
/** What the system currently lets through for [calendarIds]' reminders (#145). */
fun status(calendarIds: Collection<Long>): ReminderChannelStatus {
val manager = manager
val appEnabled = manager.areNotificationsEnabled()
val existing = manager.notificationChannels
val channels = existing.associate {
it.id to ReminderChannelState(it.importance == NotificationManager.IMPORTANCE_NONE, it.group)
}
val blockedGroups = manager.notificationChannelGroups.filter { it.isBlocked }.mapTo(HashSet()) { it.id }
return reminderChannelStatus(calendarIds, appEnabled, channels, blockedGroups, currentVersions(channels.keys))
}
/** A "Silent" (low) channel keeps its sound but never plays it. */
private fun NotificationChannel.audibleSound(): Uri? =
sound.takeUnless { importance in IMPORTANCE_MIN..IMPORTANCE_LOW }
/**
* The sound each of [calendarIds]' channels plays; null is silent. A
* calendar whose channel doesn't exist yet is left out.
*/
fun sounds(calendarIds: Collection<Long>): Map<Long, Uri?> {
val byId = manager.notificationChannels.associateBy { it.id }
val versions = currentVersions(byId.keys)
return calendarIds.mapNotNull { id ->
byId[reminderChannelId(id, versions[id] ?: 0)]?.let { id to it.audibleSound() }
}.toMap()
}
/**
* Give [calendarIds]' reminders [sound] (null = silent). A channel's sound is
* fixed once created, so each moves to a new channel version carrying over
* everything else, and the old one retires on the next sync (#518). Calendars without a
* channel are skipped unless [createMissing]. Returns whether every move
* went through.
*/
suspend fun setSound(calendarIds: Collection<Long>, sound: Uri?, createMissing: Boolean): Boolean {
if (createMissing) calendarIds.forEach(::channelFor)
val existingIds = manager.notificationChannels.mapTo(HashSet()) { it.id }
val current = currentVersions(existingIds)
val floors = calendarIds
.filter { reminderChannelId(it, current[it] ?: 0) in existingIds }
.associateWith { (current[it] ?: 0) + 1 }
if (floors.isEmpty()) return false
return recreate(settingsPrefs.reserveReminderChannelVersions(floors), sound)
}
/**
* Create each calendar's [versions] from its current channel. The old one is
* left to the next [sync], which retires it once nothing shows on it.
*/
@Synchronized
private fun recreate(versions: Map<Long, Int>, sound: Uri?): Boolean {
val manager = manager
val byId = manager.notificationChannels.associateBy { it.id }
val current = currentReminderChannelVersions(byId.keys, emptyMap())
var allApplied = true
versions.forEach { (calendarId, version) ->
val from = current[calendarId]?.takeIf { it < version }
val old = from?.let { byId[reminderChannelId(calendarId, it)] }
if (old == null) {
allApplied = false
return@forEach
}
try {
// Picking a sound asks for one, which a "Silent" (low) channel would never play.
val importance = if (sound != null && old.importance in IMPORTANCE_MIN..IMPORTANCE_LOW) {
IMPORTANCE_DEFAULT
} else {
old.importance
}
manager.createNotificationChannel(
NotificationChannel(reminderChannelId(calendarId, version), old.name, importance).apply {
description = old.description
old.group?.let { group = it }
inheritFrom(old)
setSound(sound, old.audioAttributes ?: DEFAULT_AUDIO_ATTRIBUTES)
},
)
knownVersions = knownVersions + (calendarId to version)
} catch (e: RuntimeException) {
Log.w(TAG, "Could not move calendar $calendarId to a new sound", e)
allApplied = false
}
}
lastSync = null
return allApplied
}
private fun plan(
calendars: List<CalendarSource>,
existing: List<NotificationChannel>,
existingGroupIds: List<String>,
versions: Map<Long, Int>,
showingChannelIds: Set<String> = emptySet(),
): 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),
versions = versions,
showingChannelIds = showingChannelIds,
)
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 description = context.getString(R.string.reminder_channel_description)
val template = byId[LEGACY_REMINDER_CHANNEL_ID]
// Importance only applies on creation; an existing channel keeps its own.
manager.createNotificationChannels(
channels.map { spec ->
val current = byId[spec.id]
NotificationChannel(
spec.id,
spec.name,
current?.importance ?: template?.importance ?: NotificationManager.IMPORTANCE_HIGH,
).apply {
this.description = description
spec.groupId?.let { group = it }
if (current == null && template != null) inheritFrom(template)
}
},
)
}
/**
* Carry a channel's sound, vibration, lights and badge over to a new one: the
* legacy channel to a per-calendar one on upgrade, or a calendar's channel to
* its next version (#518). Lock-screen visibility and a DND override can't
* follow: the system resets both on channels an app creates.
*/
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
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 = LEGACY_REMINDER_CHANNEL_ID in showingChannelIds(listOf(LEGACY_REMINDER_CHANNEL_ID))
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"
val DEFAULT_AUDIO_ATTRIBUTES: AudioAttributes = Notification.AUDIO_ATTRIBUTES_DEFAULT
}
}
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
@@ -27,8 +29,8 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Posts one notification per occurrence on its calendar's channel
* ([ReminderChannels], #145). Tapping opens the event's detail screen.
* Posts one notification per occurrence on a dedicated channel. 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
@@ -41,7 +43,6 @@ class ReminderNotifier @Inject constructor(
private val settingsPrefs: SettingsPrefs,
private val calendarPrefs: CalendarPrefs,
private val calendarDataSource: CalendarDataSource,
private val channels: ReminderChannels,
) {
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
@@ -52,19 +53,19 @@ class ReminderNotifier @Inject constructor(
}
/**
* 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).
* 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].
*/
private suspend fun isSilenced(calendarId: Long): Boolean =
calendarId in settingsPrefs.mutedReminderCalendarIds.first() ||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
calendarDataSource.isCalendarVisible(calendarId) == false
/** Post [alert], unless its calendar is silenced or gone. */
/** Post [alert], unless its calendar is switched off. */
suspend fun post(alert: ReminderAlert) {
if (isSilenced(alert.calendarId)) return
ensureChannel()
val title = displayTitle(alert)
val is24Hour = settingsPrefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
@@ -87,15 +88,7 @@ class ReminderNotifier @Inject constructor(
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
)
val text = listOfNotNull(time, alert.location).joinToString(" · ")
// Under the channel lock, so a sound picked meanwhile (#518) can't move it mid-post.
val posted = channels.withChannel(alert.calendarId) { channelId -> notify(alert, channelId, title, text) }
if (posted == null) {
Log.i(TAG, "Dropped the reminder of event ${alert.eventId}: no channel for calendar ${alert.calendarId}")
}
}
private fun notify(alert: ReminderAlert, channelId: String, title: String, text: String) {
val notification = NotificationCompat.Builder(context, channelId)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text)
@@ -160,7 +153,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 == LEGACY_REMINDER_CHANNEL_ID }
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == 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) }
@@ -188,8 +181,21 @@ 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.
@@ -43,7 +43,6 @@ 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,
@@ -86,14 +85,10 @@ 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 })
@@ -122,19 +117,6 @@ 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)
/**
@@ -1,6 +1,5 @@
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
@@ -73,13 +72,11 @@ fun accountGroupTitle(group: CalendarAccountGroup): String {
@Composable
fun sourceAppName(accountType: String): String {
val context = LocalContext.current
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
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
}
}
@@ -6,35 +6,22 @@ import android.content.Intent
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.provider.Settings.System.DEFAULT_NOTIFICATION_URI
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
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.material.icons.outlined.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -42,11 +29,8 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
@@ -55,14 +39,10 @@ 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.notificationSoundPickerIntent
import de.jeanlucmakiola.floret.android.openNotificationSettings
import de.jeanlucmakiola.floret.android.pickedSound
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.IconInfoCard
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.identity.collapseExit
@@ -70,7 +50,6 @@ import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalTime
/**
@@ -107,63 +86,6 @@ internal fun NotificationsScreen(
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
var calendarSectionExpanded by remember { mutableStateOf(false) }
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
val scope = rememberCoroutineScope()
val channelStatus by viewModel.channelStatus.collectAsStateWithLifecycle()
val calendarIds = state.reminderCalendars.map { it.id }
LifecycleResumeEffect(calendarIds) {
viewModel.refreshChannelStatus(calendarIds)
onPauseOrDispose { }
}
val openCalendarSettings: (Long) -> Unit = { calendarId ->
scope.launch { context.openNotificationSettings(viewModel.soundSettingsChannel(calendarId)) }
}
val openAppSettings: () -> Unit = {
scope.launch {
viewModel.syncReminderChannels()
context.openNotificationSettings()
}
}
val loadedSounds by viewModel.channelSounds.collectAsStateWithLifecycle()
val sounds = loadedSounds.orEmpty()
val soundFailedMessage = stringResource(R.string.settings_notification_sound_failed)
val soundUnreadableMessage = stringResource(R.string.settings_notification_sound_unreadable)
LaunchedEffect(Unit) {
viewModel.soundFailed.collect { failure ->
val message = if (failure == SoundFailure.Unreadable) soundUnreadableMessage else soundFailedMessage
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
}
// ALL_CALENDARS, a calendar id, or null when no pick is pending.
var soundTarget by rememberSaveable { mutableStateOf<Long?>(null) }
val soundPicker = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val target = soundTarget
soundTarget = null
val picked = pickedSound(result.resultCode, result.data) ?: return@rememberLauncherForActivityResult
when (target) {
null -> Unit
ALL_CALENDARS -> viewModel.setReminderSoundForAll(picked.uri)
else -> viewModel.setReminderSound(target, picked.uri)
}
}
val pickSoundTitle = stringResource(R.string.settings_notification_sound)
val pickSound: (Long) -> Unit = pick@{ target ->
if (soundTarget != null) return@pick
val current = if (target == ALL_CALENDARS) {
(sharedSound(sounds, calendarIds) as? SharedSound.Same)?.sound
} else {
sounds[target]
}
soundTarget = target
try {
// A missing or mixed sound preselects Default; a silent one, Silent.
val preselect = if (current == null) DEFAULT_NOTIFICATION_URI else current.uri
soundPicker.launch(notificationSoundPickerIntent(preselect, pickSoundTitle))
} catch (e: RuntimeException) {
// No picker, or one that refuses to start: the system screen still has the sound.
soundTarget = null
if (target == ALL_CALENDARS) openAppSettings() else openCalendarSettings(target)
}
}
CollapsingScaffold(
title = stringResource(R.string.settings_section_notifications),
@@ -171,16 +93,6 @@ internal fun NotificationsScreen(
predictiveBack = true,
visible = visible,
) {
if (IS_SAMSUNG && !state.notificationCategoryHintDismissed) {
NotificationCategoryHint(
onOpen = {
viewModel.dismissNotificationCategoryHint()
openAppSettings()
},
onDismiss = viewModel::dismissNotificationCategoryHint,
)
Spacer(Modifier.height(16.dp))
}
GroupedSwitchRow(
title = stringResource(R.string.settings_reminders),
checked = state.remindersEnabled,
@@ -188,25 +100,6 @@ internal fun NotificationsScreen(
summary = stringResource(R.string.settings_reminders_hint),
position = Position.Top,
)
if (calendarIds.isNotEmpty()) {
GroupedRow(
title = stringResource(R.string.settings_notification_sound),
summary = loadedSounds?.let { globalSoundLabel(it, calendarIds) },
position = Position.Middle,
onClick = { pickSound(ALL_CALENDARS) },
)
}
GroupedRow(
title = stringResource(R.string.settings_notification_more),
summary = if (channelStatus.appBlocked) {
stringResource(R.string.settings_notifications_blocked_hint)
} else {
stringResource(R.string.settings_notification_sound_hint)
},
position = Position.Middle,
trailing = { ExternalLinkIcon() },
onClick = openAppSettings,
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = reminderChoiceLabel(state.defaultReminderMinutes),
@@ -256,8 +149,10 @@ internal fun NotificationsScreen(
onClick = { showSnooze = true },
)
// Per-calendar notifications and reminder overrides (#145, #272).
if (state.reminderCalendars.isNotEmpty()) {
// 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()) {
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_calendar_reminders_title),
@@ -272,18 +167,30 @@ internal fun NotificationsScreen(
exit = collapseExit(),
) {
Column {
state.reminderCalendars.forEach { calendar ->
state.writableCalendars.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
val blocked = notifying && calendar.id in channelStatus.blockedCalendarIds
GroupedRow(
title = calendar.displayName.orUnnamed(),
summary = when {
!notifying -> stringResource(R.string.settings_calendar_notifications_off)
blocked -> stringResource(R.string.settings_notifications_blocked)
else -> null
},
position = if (expanded) Position.Top else Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = { ExpandIcon(expanded) },
@@ -300,18 +207,22 @@ internal fun NotificationsScreen(
enter = expandEnter(),
exit = collapseExit(),
) {
CalendarNotificationRows(
calendar = calendar,
state = state,
notifying = notifying,
blocked = blocked,
soundLabel = loadedSounds?.let { soundLabel(it[calendar.id]) },
onSetNotifying = { viewModel.setCalendarRemindersEnabled(calendar.id, it) },
onPickSound = { pickSound(calendar.id) },
onOpenSettings = { openCalendarSettings(calendar.id) },
onOpenSpecialDates = onOpenSpecialDates,
onEditOverride = { overrideDialog = it },
)
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) },
)
}
}
}
}
@@ -398,152 +309,6 @@ 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,
blocked: Boolean,
soundLabel: String?,
onSetNotifying: (Boolean) -> Unit,
onPickSound: () -> Unit,
onOpenSettings: () -> Unit,
onOpenSpecialDates: () -> Unit,
onEditOverride: (OverrideTarget) -> Unit,
) {
val managed = calendar.id in state.managedCalendarIds
val takesOverrides = calendar.canModifyContents && !managed
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 = soundLabel,
position = Position.Middle,
onClick = onPickSound,
)
GroupedRow(
title = stringResource(R.string.settings_notification_more),
summary = if (blocked) {
stringResource(R.string.settings_notifications_blocked_hint)
} else {
stringResource(R.string.settings_calendar_notification_sound_hint)
},
position = if (managed || takesOverrides) Position.Middle else Position.Bottom,
trailing = { ExternalLinkIcon() },
onClick = onOpenSettings,
)
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)) },
)
}
}
}
/** [soundTarget] meaning every calendar at once. */
private const val ALL_CALENDARS = Long.MIN_VALUE
/** What the calendars that have a channel share, sound-wise. */
private sealed interface SharedSound {
data object None : SharedSound
data object Mixed : SharedSound
data class Same(val sound: ReminderSound) : SharedSound
}
private fun sharedSound(sounds: Map<Long, ReminderSound>, calendarIds: List<Long>): SharedSound {
val distinct = calendarIds.mapNotNull { sounds[it] }.distinctBy { it.uri }
return when (distinct.size) {
0 -> SharedSound.None
1 -> SharedSound.Same(distinct.single())
else -> SharedSound.Mixed
}
}
@Composable
private fun soundLabel(sound: ReminderSound?): String = when {
sound == null -> stringResource(R.string.settings_notification_sound_default)
sound.uri == null -> stringResource(R.string.settings_notification_sound_silent)
sound.uri == DEFAULT_NOTIFICATION_URI -> sound.title
?.let { stringResource(R.string.settings_notification_sound_default_named, it) }
?: stringResource(R.string.settings_notification_sound_default)
else -> sound.title ?: stringResource(R.string.settings_notification_sound_custom)
}
@Composable
private fun globalSoundLabel(sounds: Map<Long, ReminderSound>, calendarIds: List<Long>): String =
when (val shared = sharedSound(sounds, calendarIds)) {
SharedSound.None -> soundLabel(null)
SharedSound.Mixed -> stringResource(R.string.settings_notification_sound_mixed)
is SharedSound.Same -> soundLabel(shared.sound)
}
/** Samsung's One UI can start a new notification category switched off (#145). */
private val IS_SAMSUNG = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
/** The one-time Samsung note, with a way into the app's notification settings. */
@Composable
private fun NotificationCategoryHint(onOpen: () -> Unit, onDismiss: () -> Unit) {
IconInfoCard(
icon = Icons.Outlined.Info,
iconContentDescription = null,
iconAlignment = Alignment.Top,
) {
Text(
text = stringResource(R.string.settings_notification_category_hint),
style = MaterialTheme.typography.bodyMedium,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.reminder_action_dismiss)) }
TextButton(onClick = onOpen) { Text(stringResource(R.string.settings_notification_category_hint_open)) }
}
}
}
@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)
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.net.Uri
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
@@ -97,14 +96,8 @@ 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(),
/** Switched-on calendars you can edit: the per-calendar event-duration rows. */
/** Writable calendars, shown as per-calendar 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(),
/** Whether the Samsung notification-category hint has been dismissed (#145); hidden until prefs load. */
val notificationCategoryHintDismissed: Boolean = true,
/**
* Whether the event-colour picker is offered on calendars that publish no
* colour palette (the colour may then not survive their next sync).
@@ -137,9 +130,3 @@ data class SpecialDatesUiState(
/** Epoch millis of the last sync, or 0 if it has never run. */
val lastRun: Long = 0L,
)
/** A reminder channel's sound: null [uri] is silent; [title] is null when it can't be resolved. */
data class ReminderSound(val uri: Uri?, val title: String?)
/** Why a picked reminder sound wasn't applied. */
enum class SoundFailure { NotApplied, Unreadable }
@@ -3,8 +3,6 @@ package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.util.Log
import androidx.datastore.preferences.core.Preferences
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -18,12 +16,10 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
import de.jeanlucmakiola.floret.android.ringtoneTitle
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
@@ -37,8 +33,6 @@ 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.ReminderChannelStatus
import de.jeanlucmakiola.calendula.data.reminders.ReminderChannels
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
@@ -51,12 +45,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.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -65,7 +54,6 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -74,13 +62,11 @@ import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs,
calendarPrefs: CalendarPrefs,
repository: CalendarRepository,
private val specialDatesEngine: SpecialDatesSyncEngine,
specialDatesSpec: SpecialDatesCalendarSpec,
private val launcherNameManager: LauncherNameManager,
private val reminderScanner: ReminderScanner,
private val reminderChannels: ReminderChannels,
@IoDispatcher private val io: CoroutineDispatcher,
@ApplicationContext private val appContext: Context,
) : ViewModel() {
@@ -88,16 +74,16 @@ class SettingsViewModel @Inject constructor(
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
/**
* Calendars that are switched on — the only ones that plan reminders at
* all (#75), so the only ones with notification settings.
* 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).
*/
private val visibleCalendars: Flow<List<CalendarSource>> =
combine(repository.calendars(), calendarPrefs.pendingDisabledCalendarIds) { calendars, pending ->
calendars.filter { it.isVisibleInSystem && it.id !in pending }
}.catch { emit(emptyList()) }
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents && it.isVisibleInSystem } }
.catch { emit(emptyList()) }
val state: StateFlow<SettingsUiState> =
combine(prefs.data, visibleCalendars) { stored, calendars -> stored.toUiState(calendars) }
combine(prefs.data, writableCalendars) { stored, calendars -> stored.toUiState(calendars) }
.stateWhileSubscribed(
scope = viewModelScope,
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
@@ -139,10 +125,7 @@ class SettingsViewModel @Inject constructor(
snoozeMinutes = this[SettingsKeys.snoozeMinutes],
perCalendarReminderOverride = this[SettingsKeys.perCalendarReminderOverride],
perCalendarAllDayReminderOverride = this[SettingsKeys.perCalendarAllDayReminderOverride],
writableCalendars = calendars.filter { it.canModifyContents },
reminderCalendars = calendars,
mutedReminderCalendarIds = this[SettingsKeys.mutedReminderCalendarIds],
notificationCategoryHintDismissed = this[SettingsKeys.notificationCategoryHintDismissed],
writableCalendars = calendars,
allowColorOnUnsupportedCalendars = this[SettingsKeys.allowColorOnUnsupportedCalendars],
managedCalendarIds = specialDatesCalendars.values.toSet(),
)
@@ -374,130 +357,6 @@ class SettingsViewModel @Inject constructor(
}
}
/**
* Re-plans, since a muted calendar armed no wake-up for its next reminder.
* Unmuting scans once more before the write, so the watermark starts at the
* unmute and nothing that came due while muted is replayed.
*/
fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
viewModelScope.launch {
if (enabled) reminderScanner.scan()
prefs.setCalendarRemindersEnabled(calendarId, enabled)
reminderScanner.scan()
}
}
private val _channelStatus = MutableStateFlow(ReminderChannelStatus())
/** What the system blocks of the reminders; re-read with [refreshChannelStatus] (#145). */
val channelStatus: StateFlow<ReminderChannelStatus> = _channelStatus.asStateFlow()
private var channelStatusJob: Job? = null
private val _channelSounds = MutableStateFlow<Map<Long, ReminderSound>?>(null)
/** Each calendar's reminder sound (#518), null until first read; a calendar without a channel yet is absent. */
val channelSounds: StateFlow<Map<Long, ReminderSound>?> = _channelSounds.asStateFlow()
private val _soundFailed = Channel<SoundFailure>(Channel.CONFLATED)
/** Emits when a picked sound couldn't be applied; kept until the screen collects it. */
val soundFailed: Flow<SoundFailure> = _soundFailed.receiveAsFlow()
private val soundTitles = mutableMapOf<Uri, String?>()
fun refreshChannelStatus(calendarIds: Collection<Long>) {
channelStatusJob?.cancel()
channelStatusJob = viewModelScope.launch {
onChannels { status(calendarIds) }?.let { _channelStatus.value = it }
val sounds = onChannels { sounds(calendarIds) } ?: return@launch
_channelSounds.value = withContext(io) {
sounds.mapValues { (_, uri) ->
ReminderSound(uri, uri?.let(::soundTitle))
}
}
}
}
/** A sound's title, cached unless it is a "default" alias whose target can change. */
private fun soundTitle(uri: Uri): String? {
if (uri.authority == Settings.AUTHORITY) return appContext.ringtoneTitle(uri)
return synchronized(soundTitles) { soundTitles.getOrPut(uri) { appContext.ringtoneTitle(uri) } }
}
/** Give [calendarId]'s reminders [sound] (null = silent), on a fresh channel (#518). */
fun setReminderSound(calendarId: Long, sound: Uri?) = applySound(sound) {
setSound(listOf(calendarId), sound, createMissing = true)
}
/**
* Give every switched-on calendar's reminders [sound]. Reads the calendars
* itself, so a pick returning after process death still finds them; those
* that only get a channel when something posts are left alone.
*/
fun setReminderSoundForAll(sound: Uri?) = applySound(sound) {
try {
sync()
} catch (e: Exception) {
Log.w("SettingsViewModel", "Channel sync before a sound pick failed", e)
}
val ids = visibleCalendars.first().map { it.id }
ids.isNotEmpty() && setSound(ids, sound, createMissing = false)
}
private fun canRead(uri: Uri): Boolean = uri.authority == Settings.AUTHORITY ||
runCatching { appContext.contentResolver.openAssetFileDescriptor(uri, "r")?.close() != null }.getOrDefault(false)
/** Runs to completion even if the screen closes meanwhile, so a pick is never half-applied. */
private fun applySound(sound: Uri?, block: suspend ReminderChannels.() -> Boolean) {
viewModelScope.launch {
val failure = withContext(io + NonCancellable) {
try {
// The system plays the sound but checks that Calendula could read it.
when {
sound != null && !canRead(sound) -> SoundFailure.Unreadable
reminderChannels.block() -> null
else -> SoundFailure.NotApplied
}
} catch (e: Exception) {
Log.w("SettingsViewModel", "Could not set the reminder sound", e)
SoundFailure.NotApplied
}
}
failure?.let(_soundFailed::trySend)
refreshChannelStatus(visibleCalendars.first().map { it.id })
}
}
fun dismissNotificationCategoryHint() = save { dismissNotificationCategoryHint() }
/**
* The channel whose system screen to open for [calendarId] (#340), created
* if needed; null when only the app's own screen can help — the app or the
* calendar's account group is blocked there, or the channel couldn't be made.
*/
suspend fun soundSettingsChannel(calendarId: Long): String? = onChannels {
val channelId = channelFor(calendarId) ?: return@onChannels null
val status = status(listOf(calendarId))
channelId.takeUnless { status.appBlocked || calendarId in status.groupBlockedCalendarIds }
}
/** 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) }
+1 -20
View File
@@ -731,26 +731,7 @@
<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">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_silent">Silent</string>
<string name="settings_notification_sound_default">Default</string>
<!-- %1$s is the name of the device's default notification sound. -->
<string name="settings_notification_sound_default_named">Default (%1$s)</string>
<string name="settings_notification_sound_custom">Custom sound</string>
<string name="settings_notification_sound_failed">Couldn\'t change the notification sound</string>
<string name="settings_notification_sound_unreadable">Calendula can\'t play this sound. Pick another one.</string>
<string name="settings_notification_sound_mixed">Differs per calendar</string>
<string name="settings_notification_more">More notification settings</string>
<string name="settings_notification_sound_hint">Vibration, pop-ups and more for each calendar, in system settings</string>
<string name="settings_notifications_blocked">Blocked in system settings</string>
<string name="settings_notifications_blocked_hint">Blocked in system settings — tap to allow</string>
<string name="settings_notification_category_hint">On Samsung phones, new notification categories can start switched off. If a calendar\'s reminders don\'t arrive, check that its category is on.</string>
<string name="settings_notification_category_hint_open">Open settings</string>
<string name="settings_calendar_notification_sound_hint">Vibration, pop-ups and more for this calendar, in system settings</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_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>
@@ -601,30 +601,6 @@ 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 `reminder channel versions only grow`(
@TempDir tempDir: Path,
) = runTest {
val prefs = testSettings(tempDir)
assertThat(prefs.reminderChannelVersions.first()).isEmpty()
assertThat(prefs.reserveReminderChannelVersions(mapOf(7L to 1))).containsExactly(7L, 1)
assertThat(prefs.reserveReminderChannelVersions(mapOf(7L to 1, 9L to 5))).containsExactly(7L, 2, 9L, 5)
assertThat(prefs.reminderChannelVersions.first()).containsExactly(7L, 2, 9L, 5)
}
@Test
fun `garbage per-calendar duration entries are dropped, the rest survive`(
@TempDir tempDir: Path,
@@ -1,200 +0,0 @@
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)
}
@Test
fun `blocked calendars follow app, channel and group`() {
val channels = mapOf(
reminderChannelId(1) to ReminderChannelState(blocked = true, groupId = "g1"),
reminderChannelId(2) to ReminderChannelState(blocked = false, groupId = "g2"),
reminderChannelId(3) to ReminderChannelState(blocked = false, groupId = "g1"),
)
val ids = listOf(1L, 2L, 3L, 4L)
val groupOff = reminderChannelStatus(ids, appEnabled = true, channels, blockedGroupIds = setOf("g2"))
assertThat(groupOff.blockedCalendarIds).containsExactly(1L, 2L)
assertThat(groupOff.groupBlockedCalendarIds).containsExactly(2L)
assertThat(reminderChannelStatus(ids, appEnabled = true, channels, blockedGroupIds = emptySet()).blockedCalendarIds)
.containsExactly(1L)
val appOff = reminderChannelStatus(ids, appEnabled = false, channels, blockedGroupIds = emptySet())
assertThat(appOff.appBlocked).isTrue()
assertThat(appOff.blockedCalendarIds).containsExactly(1L, 2L, 3L, 4L)
}
@Test
fun `channel ids round-trip with versions`() {
assertThat(parseReminderChannelId(reminderChannelId(7))).isEqualTo(7L to 0)
assertThat(parseReminderChannelId(reminderChannelId(7, 3))).isEqualTo(7L to 3)
assertThat(parseReminderChannelId("reminders.calendar.7.v0")).isNull()
assertThat(parseReminderChannelId("reminders.calendar.x")).isNull()
assertThat(parseReminderChannelId(LEGACY_REMINDER_CHANNEL_ID)).isNull()
}
@Test
fun `a version bump retires the old channel unless it is showing`() {
val existing = listOf(
ExistingReminderChannel(reminderChannelId(1), "Cal 1", DESC, "g"),
ExistingReminderChannel(reminderChannelId(2), "Cal 2", DESC, "g"),
)
val plan = planReminderChannels(
calendars = listOf(calendar(1, "a", "com.google"), calendar(2, "a", "com.google")),
existing = existing,
existingGroupIds = emptyList(),
unnamed = "Unnamed",
description = DESC,
versions = mapOf(1L to 2, 2L to 1),
showingChannelIds = setOf(reminderChannelId(2)),
)
assertThat(plan.channels.map { it.id }).containsExactly(reminderChannelId(1, 2), reminderChannelId(2, 1))
assertThat(plan.staleChannelIds).containsExactly(reminderChannelId(1))
assertThat(plan.keptChannelIds).containsExactly(reminderChannelId(2))
}
@Test
fun `the current version is the highest that exists, else the high-water mark`() {
val versions = currentReminderChannelVersions(
existingChannelIds = listOf(reminderChannelId(1), reminderChannelId(1, 2), reminderChannelId(3), "backup"),
highWater = mapOf(1L to 5, 2L to 4),
)
// Calendar 1 reserved v5 but never wrote it, so v2 stays current.
assertThat(versions).containsExactly(1L, 2, 2L, 4, 3L, 0)
}
@Test
fun `status reads the current channel version`() {
val channels = mapOf(
reminderChannelId(1) to ReminderChannelState(blocked = true, groupId = null),
reminderChannelId(1, 2) to ReminderChannelState(blocked = false, groupId = null),
)
val status = reminderChannelStatus(listOf(1L), true, channels, emptySet(), versions = mapOf(1L to 2))
assertThat(status.blockedCalendarIds).isEmpty()
}
private companion object {
const val DESC = "Reminders"
}
}
-30
View File
@@ -391,36 +391,6 @@ 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 deep-links into the
system's channel screens for everything but the sound (#340). A sound picked in
the app (#518) can't be set on an existing channel, so
`ReminderChannels.setSound` recreates it under a new version
(`reminders.calendar.<id>.v<n>`), copying everything else from the old one. The
current version is simply the highest channel that exists, so the new one only
takes over once written; `SettingsPrefs.reminderChannelVersions` is just a
high-water mark that keeps ids from being reused (a re-used id would bring back
the deleted channel's settings). `setSound` leaves the old version to the sync,
which treats older versions as stale and keeps one while a notification still
shows on it; `ReminderNotifier.post` resolves and posts under the channel lock
(`withChannel`), so a pick never moves a channel mid-post.
Separate from the channels, `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. The settings screen re-reads `ReminderChannels.status` on every
resume and marks a calendar "Blocked in system settings" when the app, its
channel or its account group is switched off there.
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