fix(#145): review round 1 — vibration carry-over, cheaper channel sync, shared account grouping
This commit is contained in:
1 parent
994fc1026f
commit
9b5880b719
6 files changed
+146
-112
No files matched your search
+39
-36
@@ -1,6 +1,7 @@
|
||||
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"
|
||||
@@ -19,13 +20,13 @@ fun isReminderChannelId(channelId: String): Boolean = channelId.startsWith(CHANN
|
||||
fun isReminderGroupId(groupId: String): Boolean = groupId.startsWith(GROUP_PREFIX)
|
||||
|
||||
/**
|
||||
* One account's channel group. The app's own device calendars share a single
|
||||
* group; [ambiguous] marks a name another group shows under a different type,
|
||||
* so the label can name the source app as well.
|
||||
* 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 accountName: String,
|
||||
val label: String,
|
||||
val accountType: String,
|
||||
val isLocal: Boolean,
|
||||
val ambiguous: Boolean,
|
||||
@@ -39,67 +40,69 @@ data class ReminderChannelSpec(
|
||||
val groupId: String,
|
||||
)
|
||||
|
||||
/** 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)
|
||||
|
||||
data class ReminderChannelPlan(
|
||||
val groups: List<ReminderChannelGroupSpec>,
|
||||
/** Every channel that should exist; creating an existing one only refreshes its name. */
|
||||
val channels: List<ReminderChannelSpec>,
|
||||
/** The [channels] that are missing or whose name changed. */
|
||||
val channelsToWrite: List<ReminderChannelSpec>,
|
||||
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, and the reminder channels and groups in
|
||||
* [existingChannelIds] / [existingGroupIds] that no calendar claims any more.
|
||||
* Ids outside the reminder namespace (the backup channel, the legacy channel)
|
||||
* are never touched.
|
||||
* 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>,
|
||||
existingChannelIds: Collection<String>,
|
||||
existing: Collection<ExistingReminderChannel>,
|
||||
existingGroupIds: Collection<String>,
|
||||
unnamed: String,
|
||||
): ReminderChannelPlan {
|
||||
val groupsByKey = calendars.groupBy(::accountKey)
|
||||
val nameCounts = groupsByKey.keys.filterNotNull().groupingBy { it.first }.eachCount()
|
||||
val groups = groupsByKey.map { (key, members) ->
|
||||
if (key == null) {
|
||||
ReminderChannelGroupSpec(
|
||||
id = LOCAL_GROUP_ID,
|
||||
accountName = members.first().accountName,
|
||||
accountType = members.first().accountType,
|
||||
isLocal = true,
|
||||
ambiguous = false,
|
||||
)
|
||||
} else {
|
||||
val (name, type) = key
|
||||
ReminderChannelGroupSpec(
|
||||
id = "$GROUP_PREFIX$type/$name",
|
||||
accountName = name,
|
||||
accountType = type,
|
||||
isLocal = false,
|
||||
ambiguous = nameCounts.getValue(name) > 1,
|
||||
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 groupIdByKey = groupsByKey.keys.zip(groups.map { it.id }).toMap()
|
||||
val channels = calendars.map { calendar ->
|
||||
ReminderChannelSpec(
|
||||
id = reminderChannelId(calendar.id),
|
||||
calendarId = calendar.id,
|
||||
name = calendar.displayName,
|
||||
groupId = groupIdByKey.getValue(accountKey(calendar)),
|
||||
name = calendar.displayName.ifBlank { unnamed },
|
||||
groupId = if (calendar.isLocal) LOCAL_GROUP_ID else accountGroupId(calendar.accountType, calendar.accountName),
|
||||
)
|
||||
}
|
||||
val existingById = existing.associateBy { it.id }
|
||||
val wantedChannels = channels.mapTo(HashSet()) { it.id }
|
||||
val wantedGroups = groups.mapTo(HashSet()) { it.id }
|
||||
return ReminderChannelPlan(
|
||||
groups = groups,
|
||||
channels = channels,
|
||||
staleChannelIds = existingChannelIds.filter { isReminderChannelId(it) && it !in wantedChannels },
|
||||
channelsToWrite = channels.filter { spec ->
|
||||
val current = existingById[spec.id]
|
||||
current == null || current.name != spec.name
|
||||
},
|
||||
staleChannelIds = existingById.keys.filter { isReminderChannelId(it) && it !in wantedChannels },
|
||||
staleGroupIds = existingGroupIds.filter { isReminderGroupId(it) && it !in wantedGroups },
|
||||
)
|
||||
}
|
||||
|
||||
/** The app's own device calendars share one group (null); every other account has its own. */
|
||||
private fun accountKey(calendar: CalendarSource): Pair<String, String>? =
|
||||
if (calendar.isLocal) null else calendar.accountName to calendar.accountType
|
||||
private fun accountGroupId(type: String, name: String): String = "$GROUP_PREFIX$type/$name"
|
||||
@@ -8,44 +8,39 @@ 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.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppPackages
|
||||
import kotlinx.coroutines.flow.first
|
||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppLabel
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The reminder notification channels: one per switched-on calendar, grouped
|
||||
* by account (#145), so each calendar can be muted or given its own sound in
|
||||
* the system settings before it ever fires.
|
||||
* 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, refreshes names, and deletes channels whose calendar is gone or
|
||||
* switched off — the system restores a deleted channel's settings if its id
|
||||
* comes back.
|
||||
* 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 calendarPrefs: CalendarPrefs,
|
||||
) {
|
||||
private val manager: NotificationManager
|
||||
get() = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
/** Bring the channels in line with the provider's switched-on calendars. */
|
||||
suspend fun sync() {
|
||||
val pending = calendarPrefs.pendingDisabledCalendarIds.first()
|
||||
val calendars = calendarDataSource.calendars().filter { it.isVisibleInSystem && it.id !in pending }
|
||||
/** Bring the channels in line with the provider's calendars. */
|
||||
fun sync() {
|
||||
val manager = manager
|
||||
val plan = planReminderChannels(
|
||||
calendars = calendars,
|
||||
existingChannelIds = manager.notificationChannels.map { it.id },
|
||||
existingGroupIds = manager.notificationChannelGroups.map { it.id },
|
||||
)
|
||||
val existing = manager.notificationChannels
|
||||
val plan = plan(existing)
|
||||
plan.staleChannelIds.forEach(manager::deleteNotificationChannel)
|
||||
plan.staleGroupIds.forEach(manager::deleteNotificationChannelGroup)
|
||||
create(plan)
|
||||
val groupNames = manager.notificationChannelGroups.associate { it.id to it.name?.toString() }
|
||||
write(
|
||||
groups = plan.groups.filter { groupNames[it.id] != groupLabel(it) },
|
||||
channels = plan.channelsToWrite,
|
||||
existing = existing,
|
||||
)
|
||||
if (plan.channels.isNotEmpty()) retireLegacyChannel()
|
||||
}
|
||||
|
||||
@@ -55,32 +50,52 @@ class ReminderChannels @Inject constructor(
|
||||
*/
|
||||
fun channelFor(calendarId: Long): String? {
|
||||
val id = reminderChannelId(calendarId)
|
||||
val manager = manager
|
||||
if (manager.getNotificationChannel(id) != null) return id
|
||||
val calendar = calendarDataSource.calendars().firstOrNull { it.id == calendarId } ?: return null
|
||||
create(planReminderChannels(listOf(calendar), emptyList(), emptyList()))
|
||||
val existing = manager.notificationChannels
|
||||
val plan = plan(existing)
|
||||
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 create(plan: ReminderChannelPlan) {
|
||||
private fun plan(existing: List<NotificationChannel>): ReminderChannelPlan = planReminderChannels(
|
||||
calendars = calendarDataSource.calendars(),
|
||||
existing = existing.map { ExistingReminderChannel(it.id, it.name.toString()) },
|
||||
existingGroupIds = manager.notificationChannelGroups.map { it.id },
|
||||
unnamed = context.getString(R.string.calendar_unnamed),
|
||||
)
|
||||
|
||||
private fun write(
|
||||
groups: List<ReminderChannelGroupSpec>,
|
||||
channels: List<ReminderChannelSpec>,
|
||||
existing: List<NotificationChannel>,
|
||||
) {
|
||||
val manager = manager
|
||||
manager.createNotificationChannelGroups(
|
||||
plan.groups.map { NotificationChannelGroup(it.id, groupLabel(it)) },
|
||||
)
|
||||
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.
|
||||
val template = manager.getNotificationChannel(LEGACY_REMINDER_CHANNEL_ID)
|
||||
val unnamed = context.getString(R.string.calendar_unnamed)
|
||||
manager.createNotificationChannels(
|
||||
plan.channels.map { spec ->
|
||||
val existing = manager.getNotificationChannel(spec.id)
|
||||
channels.map { spec ->
|
||||
val current = byId[spec.id]
|
||||
NotificationChannel(
|
||||
spec.id,
|
||||
spec.name.ifBlank { unnamed },
|
||||
existing?.importance ?: template?.importance ?: NotificationManager.IMPORTANCE_HIGH,
|
||||
spec.name,
|
||||
current?.importance ?: template?.importance ?: NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = context.getString(R.string.reminder_channel_description)
|
||||
group = spec.groupId
|
||||
if (existing == null && template != null) inheritFrom(template)
|
||||
if (current == null && template != null) inheritFrom(template)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -92,8 +107,9 @@ class ReminderChannels @Inject constructor(
|
||||
*/
|
||||
private fun NotificationChannel.inheritFrom(template: NotificationChannel) {
|
||||
setSound(template.sound, template.audioAttributes)
|
||||
enableVibration(template.shouldVibrate())
|
||||
// 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
|
||||
@@ -119,17 +135,9 @@ class ReminderChannels @Inject constructor(
|
||||
|
||||
private fun groupLabel(group: ReminderChannelGroupSpec): String {
|
||||
if (group.isLocal) return context.getString(R.string.calendars_local_header)
|
||||
val name = group.accountName.ifBlank { group.accountType }
|
||||
.ifBlank { context.getString(R.string.calendar_unnamed) }
|
||||
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(group.accountType))
|
||||
}
|
||||
|
||||
private fun sourceAppLabel(accountType: String): String {
|
||||
val pm = context.packageManager
|
||||
return sourceAppPackages(context, accountType).firstNotNullOfOrNull { pkg ->
|
||||
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
|
||||
} ?: accountType
|
||||
return context.getString(R.string.calendars_account_from_source, name, sourceAppLabel(context, group.accountType))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -14,7 +14,6 @@ import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -78,7 +77,6 @@ class ReminderScanner @Inject constructor(
|
||||
private suspend fun runScan() {
|
||||
val now = System.currentTimeMillis()
|
||||
if (!hasReadCalendar()) return
|
||||
syncChannels()
|
||||
|
||||
if (!settingsPrefs.remindersEnabled.first()) {
|
||||
// Reminders off: drop the wake-up, but keep the watermark moving
|
||||
@@ -88,6 +86,7 @@ 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.
|
||||
@@ -121,14 +120,13 @@ class ReminderScanner @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Every trigger that can add, rename or drop a calendar reaches a scan, so
|
||||
* the channels are kept in step here. A failure never costs the reminders.
|
||||
* 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 suspend fun syncChannels() {
|
||||
private fun syncChannels() {
|
||||
try {
|
||||
channels.sync()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+35
-13
@@ -24,8 +24,9 @@ class ReminderChannelPlanTest {
|
||||
calendar(2, "me@example.org", "com.google"),
|
||||
calendar(3, "dav", "bitfire.at.davdroid"),
|
||||
),
|
||||
existingChannelIds = emptyList(),
|
||||
existing = emptyList(),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
)
|
||||
assertThat(plan.groups).hasSize(2)
|
||||
assertThat(plan.channels.map { it.id })
|
||||
@@ -42,8 +43,9 @@ class ReminderChannelPlanTest {
|
||||
calendar(1, "Calendula", "LOCAL", local = true),
|
||||
calendar(2, "Other", "LOCAL", local = true),
|
||||
),
|
||||
existingChannelIds = emptyList(),
|
||||
existing = emptyList(),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
)
|
||||
assertThat(plan.groups).hasSize(1)
|
||||
assertThat(plan.groups.single().isLocal).isTrue()
|
||||
@@ -57,27 +59,47 @@ class ReminderChannelPlanTest {
|
||||
calendar(2, "me@example.org", "bitfire.at.davdroid"),
|
||||
calendar(3, "work", "bitfire.at.davdroid"),
|
||||
),
|
||||
existingChannelIds = emptyList(),
|
||||
existing = emptyList(),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
)
|
||||
assertThat(plan.groups.filter { it.ambiguous }.map { it.accountType })
|
||||
.containsExactly("com.google", "bitfire.at.davdroid")
|
||||
assertThat(plan.groups.single { it.accountName == "work" }.ambiguous).isFalse()
|
||||
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")
|
||||
val keptGroup = planReminderChannels(listOf(current), emptyList(), emptyList()).groups.single().id
|
||||
val gone = planReminderChannels(listOf(calendar(9, "old", "com.google")), emptyList(), emptyList())
|
||||
val plan = planReminderChannels(
|
||||
fun plan(calendars: List<CalendarSource>, existing: List<String> = emptyList(), groups: List<String> = emptyList()) =
|
||||
planReminderChannels(calendars, existing.map { ExistingReminderChannel(it, "Cal ${it.last()}") }, groups, "Unnamed")
|
||||
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),
|
||||
existingChannelIds = listOf(
|
||||
reminderChannelId(1), reminderChannelId(9), LEGACY_REMINDER_CHANNEL_ID, "backup",
|
||||
),
|
||||
existingGroupIds = listOf(keptGroup, gone.groups.single().id, "other"),
|
||||
existing = listOf(reminderChannelId(1), reminderChannelId(9), LEGACY_REMINDER_CHANNEL_ID, "backup"),
|
||||
groups = listOf(keptGroup, goneGroup, "other"),
|
||||
)
|
||||
assertThat(plan.staleChannelIds).containsExactly(reminderChannelId(9))
|
||||
assertThat(plan.staleGroupIds).containsExactly(gone.groups.single().id)
|
||||
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 = ""),
|
||||
),
|
||||
existing = listOf(
|
||||
ExistingReminderChannel(reminderChannelId(1), "Cal 1"),
|
||||
ExistingReminderChannel(reminderChannelId(2), "Old name"),
|
||||
),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
)
|
||||
assertThat(plan.channelsToWrite.map { it.calendarId }).containsExactly(2L, 3L)
|
||||
assertThat(plan.channelsToWrite.last().name).isEqualTo("Unnamed")
|
||||
}
|
||||
}
|
||||
@@ -392,11 +392,11 @@ 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 switched-on calendar, grouped by account, so a calendar can be muted
|
||||
or given its own sound in the system settings before it ever fires. Every scan
|
||||
syncs them first: missing channels are created, names refreshed, and channels of
|
||||
gone or switched-off calendars deleted (the system restores a deleted channel's
|
||||
settings if the id returns). A new channel copies the sound, vibration and
|
||||
channel per calendar, grouped by account, 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: missing channels are created, renamed ones updated, and
|
||||
channels of deleted calendars removed. Switched-off calendars keep theirs, so
|
||||
toggling one never cancels a showing reminder or loses its settings. 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
|
||||
|
||||
Reference in new issue
Block a user