Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7361e8d98e | ||
|
|
9178828b41 | ||
|
|
0b6cdf992e | ||
|
|
014d31f9b9 | ||
|
|
4287862f47 | ||
|
|
6cf6a6fdbf | ||
|
|
d2b6637f72 | ||
|
|
0b223f53fc | ||
|
|
c52c6c54cc | ||
|
|
fa999f8cf0 | ||
|
|
515c7c3d26 | ||
|
|
9b5880b719 | ||
|
|
994fc1026f |
No files matched your search
@@ -182,6 +182,7 @@ dependencies {
|
||||
implementation("de.jeanlucmakiola.floret:identity")
|
||||
implementation("de.jeanlucmakiola.floret:components")
|
||||
implementation("de.jeanlucmakiola.floret:glance")
|
||||
implementation("de.jeanlucmakiola.floret:ring")
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
|
||||
@@ -41,10 +41,15 @@
|
||||
-->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- WorkManager declares these for network constraints and foreground workers, neither of which the app uses (#448). -->
|
||||
<!-- WorkManager declares this for network constraints, which the app doesn't use (#448). -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" tools:node="remove" />
|
||||
|
||||
<!-- Full-screen reminder alerts and their ring (#171), opt-in per calendar. -->
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||
returns null and the calendar manager's per-account "manage" button can't
|
||||
@@ -322,6 +327,20 @@
|
||||
android:name=".data.reminders.ReminderActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- The full-screen reminder alert (#171), in a task of its own. -->
|
||||
<activity
|
||||
android:name=".ui.reminders.ReminderAlertActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTask"
|
||||
android:taskAffinity="" />
|
||||
|
||||
<!-- Rings a "Ring until dismissed" reminder (#171). -->
|
||||
<service
|
||||
android:name=".data.reminders.ReminderRingService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="systemExempted" />
|
||||
|
||||
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||
<activity
|
||||
android:name=".widget.config.WidgetConfigActivity"
|
||||
|
||||
@@ -81,7 +81,7 @@ class CalendarPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
|
||||
private fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
|
||||
internal fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
|
||||
{ stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() },
|
||||
{ ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") },
|
||||
)
|
||||
@@ -5,6 +5,9 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.parseAlertStyles
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.toStoredAlertStyles
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
@@ -166,6 +169,8 @@ class SettingsPrefs @Inject constructor(
|
||||
val defaultEventDurationMinutes: Flow<Int> = prefs.flow(SettingsKeys.defaultEventDurationMinutes)
|
||||
val perCalendarEventDuration: Flow<Map<Long, Int>> = prefs.flow(SettingsKeys.perCalendarEventDuration)
|
||||
val remindersEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.remindersEnabled)
|
||||
val mutedReminderCalendarIds: Flow<Set<Long>> = prefs.flow(SettingsKeys.mutedReminderCalendarIds)
|
||||
val reminderAlertStyles: Flow<Map<Long, ReminderAlertStyle>> = prefs.flow(SettingsKeys.reminderAlertStyles)
|
||||
val allowColorOnUnsupportedCalendars: Flow<Boolean> =
|
||||
prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars)
|
||||
val defaultReminderMinutes: Flow<List<Int>> = prefs.flow(SettingsKeys.defaultReminderMinutes)
|
||||
@@ -273,6 +278,14 @@ class SettingsPrefs @Inject constructor(
|
||||
|
||||
suspend fun setRemindersEnabled(enabled: Boolean) = prefs.set(SettingsKeys.remindersEnabled, enabled)
|
||||
|
||||
suspend fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
|
||||
prefs.update(SettingsKeys.mutedReminderCalendarIds) { if (enabled) it - calendarId else it + calendarId }
|
||||
}
|
||||
|
||||
suspend fun setCalendarAlertStyle(calendarId: Long, style: ReminderAlertStyle) {
|
||||
prefs.update(SettingsKeys.reminderAlertStyles) { it + (calendarId to style) }
|
||||
}
|
||||
|
||||
suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) =
|
||||
prefs.set(SettingsKeys.allowColorOnUnsupportedCalendars, enabled)
|
||||
|
||||
@@ -594,6 +607,16 @@ object SettingsKeys {
|
||||
*/
|
||||
val remindersEnabled: Pref<Boolean> = booleanPref("reminders_enabled", true)
|
||||
|
||||
/**
|
||||
* Calendars whose reminders Calendula doesn't post (#272), independent of
|
||||
* `Calendars.VISIBLE`: the events still show, only the notifications stop.
|
||||
*/
|
||||
val mutedReminderCalendarIds: Pref<Set<Long>> = idSetPref("muted_reminder_calendar_ids")
|
||||
|
||||
/** Per-calendar alert styles (#171); a calendar absent from the map uses plain notifications. */
|
||||
val reminderAlertStyles: Pref<Map<Long, ReminderAlertStyle>> =
|
||||
nullableStringPref("reminder_alert_styles").map(::parseAlertStyles) { it.toStoredAlertStyles() }
|
||||
|
||||
/**
|
||||
* Whether to offer a custom event colour on calendars that publish no colour
|
||||
* palette; synced ones without a palette (some CalDAV) may drop or overwrite
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* What the full-screen alerts (#171) share within the process: the reminder
|
||||
* [ReminderRingService] is ringing for, and the reminders just dismissed or
|
||||
* snoozed, so an alert screen showing one closes whichever way it was handled.
|
||||
*/
|
||||
@Singleton
|
||||
class FullScreenAlerts @Inject constructor() {
|
||||
|
||||
/** Written only by [ReminderRingService], on the main thread, for as long as it rings. */
|
||||
@Volatile
|
||||
var ringing: ReminderAlert? = null
|
||||
internal set
|
||||
|
||||
/** Rings handed to [ReminderRingService] that have not reached it yet, with how many starts each. */
|
||||
private val pending = ConcurrentHashMap<Long, Pair<ReminderAlert, Int>>()
|
||||
|
||||
/** Set when the service is stopped from outside but its ring should go back to the shade. */
|
||||
@Volatile
|
||||
internal var handBackOnDestroy = false
|
||||
|
||||
internal fun addPending(alert: ReminderAlert) {
|
||||
pending.compute(alert.key) { _, entry -> alert to ((entry?.second ?: 0) + 1) }
|
||||
}
|
||||
|
||||
internal fun removePending(alert: ReminderAlert) {
|
||||
pending.computeIfPresent(alert.key) { _, entry -> if (entry.second <= 1) null else entry.first to entry.second - 1 }
|
||||
}
|
||||
|
||||
internal fun isPending(alert: ReminderAlert): Boolean = pending.containsKey(alert.key)
|
||||
|
||||
internal val anyPending: Boolean get() = pending.isNotEmpty()
|
||||
|
||||
/**
|
||||
* Whether a ring going or on its way [match]es, so a stop is worth sending.
|
||||
* Pending first: the service sets [ringing] before it lets a start go.
|
||||
*/
|
||||
fun mayRing(match: RingMatch): Boolean =
|
||||
pending.values.any { match.matches(it.first) } || ringing?.let(match::matches) == true
|
||||
|
||||
private val handledKeys = MutableSharedFlow<Long>(
|
||||
extraBufferCapacity = 8,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/** The [ReminderAlert.key]s of reminders as they are dismissed, snoozed or handed back. */
|
||||
val handled: SharedFlow<Long> = handledKeys.asSharedFlow()
|
||||
|
||||
fun markHandled(alert: ReminderAlert) {
|
||||
handledKeys.tryEmit(alert.key)
|
||||
}
|
||||
}
|
||||
|
||||
/** What becomes of a ring once its settings are re-read. */
|
||||
enum class RingVerdict { Ring, HandBack, Drop }
|
||||
|
||||
/** Which ring a stop is for. */
|
||||
sealed interface RingMatch {
|
||||
fun matches(alert: ReminderAlert): Boolean
|
||||
|
||||
data class Key(val key: Long) : RingMatch {
|
||||
override fun matches(alert: ReminderAlert) = alert.key == key
|
||||
}
|
||||
|
||||
data class Occurrence(val occurrenceKey: Long) : RingMatch {
|
||||
override fun matches(alert: ReminderAlert) = alert.occurrenceKey == occurrenceKey
|
||||
}
|
||||
|
||||
data class Calendar(val calendarId: Long) : RingMatch {
|
||||
override fun matches(alert: ReminderAlert) = alert.calendarId == calendarId
|
||||
}
|
||||
|
||||
data object Any : RingMatch {
|
||||
override fun matches(alert: ReminderAlert) = true
|
||||
}
|
||||
}
|
||||
+20
-11
@@ -13,8 +13,8 @@ import javax.inject.Inject
|
||||
/**
|
||||
* Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus
|
||||
* the internal re-show when a snooze elapses. All three are app-internal
|
||||
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||
* alarm), so the receiver is not exported.
|
||||
* intents (notification action buttons, the full-screen alert's buttons and
|
||||
* our own [ReminderSnoozeScheduler] alarm), so the receiver is not exported.
|
||||
*
|
||||
* - **Dismiss** just cancels the notification — the scan's watermark has moved
|
||||
* past this reminder, so nothing re-posts it.
|
||||
@@ -74,6 +74,9 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
*/
|
||||
const val ACTION_OPEN = "de.jeanlucmakiola.calendula.reminders.OPEN"
|
||||
|
||||
/** Not handled here either: the slot of the full-screen alert's own intent (#171). */
|
||||
const val ACTION_ALERT_SCREEN = "de.jeanlucmakiola.calendula.reminders.ALERT_SCREEN"
|
||||
|
||||
private const val EXTRA_ALERT_KEY = "alert_key"
|
||||
private const val EXTRA_EVENT_ID = "event_id"
|
||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||
@@ -94,16 +97,21 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||
this.action = action
|
||||
data = "calendula://reminder/${identity(alert, action)}".toUri()
|
||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
putExtra(EXTRA_END, alert.endMillis)
|
||||
putExtra(EXTRA_TITLE, alert.title)
|
||||
putExtra(EXTRA_LOCATION, alert.location)
|
||||
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
|
||||
putAlert(alert)
|
||||
}
|
||||
|
||||
/** Carries [alert] as extras, for [alertFrom] to read back. */
|
||||
fun Intent.putAlert(alert: ReminderAlert): Intent = apply {
|
||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
putExtra(EXTRA_END, alert.endMillis)
|
||||
putExtra(EXTRA_TITLE, alert.title)
|
||||
putExtra(EXTRA_LOCATION, alert.location)
|
||||
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable request code per (alert, action), so one notification's
|
||||
* PendingIntents stay distinct. The shift keeps the action slot intact;
|
||||
@@ -115,6 +123,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
ACTION_DISMISS -> 2
|
||||
ACTION_SHOW -> 3
|
||||
ACTION_OPEN -> 4
|
||||
ACTION_ALERT_SCREEN -> 5
|
||||
else -> 0
|
||||
}
|
||||
return (identity(alert, action).toInt() shl 3) + actionOffset
|
||||
@@ -123,7 +132,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
private fun identity(alert: ReminderAlert, action: String): Long =
|
||||
if (action == ACTION_SHOW) alert.occurrenceKey else alert.key
|
||||
|
||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||
return ReminderAlert(
|
||||
key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
|
||||
/** The single channel every reminder posted on before they moved per calendar (#145). */
|
||||
const val LEGACY_REMINDER_CHANNEL_ID = "reminders"
|
||||
|
||||
/** The silent channel ringing full-screen alerts post on (#171); outside the per-calendar namespace. */
|
||||
const val RING_CHANNEL_ID = "reminders_ring"
|
||||
|
||||
private const val CHANNEL_PREFIX = "reminders.calendar."
|
||||
private const val GROUP_PREFIX = "reminders.account."
|
||||
private const val LOCAL_GROUP_ID = GROUP_PREFIX + "local"
|
||||
|
||||
/** The notification channel [calendarId]'s reminders post on. */
|
||||
fun reminderChannelId(calendarId: Long): String = CHANNEL_PREFIX + calendarId
|
||||
|
||||
/** Whether [channelId] is one of the per-calendar reminder channels. */
|
||||
fun isReminderChannelId(channelId: String): Boolean = channelId.startsWith(CHANNEL_PREFIX)
|
||||
|
||||
/** Whether [groupId] is one of the per-account reminder channel groups. */
|
||||
fun isReminderGroupId(groupId: String): Boolean = groupId.startsWith(GROUP_PREFIX)
|
||||
|
||||
/**
|
||||
* One account's channel group, labelled as the calendar manager labels it. The
|
||||
* app's own device calendars share a single group ([isLocal]); [ambiguous]
|
||||
* marks a [label] another group shows too, so it should name the source app.
|
||||
*/
|
||||
data class ReminderChannelGroupSpec(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val accountType: String,
|
||||
val isLocal: Boolean,
|
||||
val ambiguous: Boolean,
|
||||
)
|
||||
|
||||
data class ReminderChannelSpec(
|
||||
val id: String,
|
||||
val calendarId: Long,
|
||||
val name: String,
|
||||
/** Null only for a channel created before its calendar could be looked up. */
|
||||
val groupId: String?,
|
||||
/**
|
||||
* Whether a sync creates it up front. A calendar that syncs no events never
|
||||
* fires, so its channel only appears once something posts on it.
|
||||
*/
|
||||
val upFront: Boolean = true,
|
||||
)
|
||||
|
||||
/** What a channel already looks like in the system, as far as the plan compares it. */
|
||||
data class ExistingReminderChannel(val id: String, val name: String, val description: String?, val groupId: String?)
|
||||
|
||||
data class ReminderChannelPlan(
|
||||
val groups: List<ReminderChannelGroupSpec>,
|
||||
val channels: List<ReminderChannelSpec>,
|
||||
/**
|
||||
* The [channels] a sync writes: missing up-front ones, and existing ones
|
||||
* whose name or (localized) description changed, or that still lack a group.
|
||||
*/
|
||||
val channelsToWrite: List<ReminderChannelSpec>,
|
||||
/** The [groups] holding a channel that exists or is about to be written. */
|
||||
val groupsInUse: List<ReminderChannelGroupSpec>,
|
||||
val staleChannelIds: List<String>,
|
||||
val staleGroupIds: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* What the reminder channels should look like for [calendars]: one channel per
|
||||
* calendar inside a group per account, which of them need writing, and the
|
||||
* reminder channels and groups in [existing] / [existingGroupIds] no calendar
|
||||
* claims any more. Ids outside the reminder namespace (the backup channel, the
|
||||
* legacy channel) are never touched.
|
||||
*/
|
||||
fun planReminderChannels(
|
||||
calendars: List<CalendarSource>,
|
||||
existing: Collection<ExistingReminderChannel>,
|
||||
existingGroupIds: Collection<String>,
|
||||
unnamed: String,
|
||||
description: String,
|
||||
): ReminderChannelPlan {
|
||||
val (local, synced) = calendars.partition { it.isLocal }
|
||||
val groups = buildList {
|
||||
if (local.isNotEmpty()) {
|
||||
add(ReminderChannelGroupSpec(LOCAL_GROUP_ID, "", local.first().accountType, isLocal = true, ambiguous = false))
|
||||
}
|
||||
synced.groupByAccount().forEach { group ->
|
||||
add(
|
||||
ReminderChannelGroupSpec(
|
||||
id = accountGroupId(group.key.type, group.key.name),
|
||||
label = group.label,
|
||||
accountType = group.accountType,
|
||||
isLocal = false,
|
||||
ambiguous = group.ambiguous,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val channels = calendars.map { calendar ->
|
||||
ReminderChannelSpec(
|
||||
id = reminderChannelId(calendar.id),
|
||||
calendarId = calendar.id,
|
||||
name = calendar.displayName.ifBlank { unnamed },
|
||||
groupId = if (calendar.isLocal) LOCAL_GROUP_ID else accountGroupId(calendar.accountType, calendar.accountName),
|
||||
upFront = calendar.isLocal || calendar.syncsEvents,
|
||||
)
|
||||
}
|
||||
val existingById = existing.associateBy { it.id }
|
||||
val wantedChannels = channels.mapTo(HashSet()) { it.id }
|
||||
val wantedGroups = groups.mapTo(HashSet()) { it.id }
|
||||
val toWrite = channels.filter { spec ->
|
||||
val current = existingById[spec.id]
|
||||
if (current == null) {
|
||||
spec.upFront
|
||||
} else {
|
||||
current.name != spec.name || current.description != description ||
|
||||
(current.groupId == null && spec.groupId != null)
|
||||
}
|
||||
}
|
||||
val usedGroupIds = channels
|
||||
.filter { it.id in existingById || it in toWrite }
|
||||
.mapNotNullTo(HashSet()) { it.groupId }
|
||||
return ReminderChannelPlan(
|
||||
groups = groups,
|
||||
channels = channels,
|
||||
channelsToWrite = toWrite,
|
||||
groupsInUse = groups.filter { it.id in usedGroupIds },
|
||||
staleChannelIds = existingById.keys.filter { isReminderChannelId(it) && it !in wantedChannels },
|
||||
staleGroupIds = existingGroupIds.filter { isReminderGroupId(it) && it !in wantedGroups },
|
||||
)
|
||||
}
|
||||
|
||||
private fun accountGroupId(type: String, name: String): String = "$GROUP_PREFIX$type/$name"
|
||||
@@ -0,0 +1,243 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationChannelGroup
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppLabel
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The reminder notification channels: one per calendar, grouped by account
|
||||
* (#145), created up front so each calendar can be muted or given its own sound
|
||||
* in the system settings before it ever fires.
|
||||
*
|
||||
* A channel the user has touched keeps their settings; [sync] only creates the
|
||||
* missing ones, renames changed ones, and deletes those whose calendar is gone.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderChannels @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
) {
|
||||
private val manager: NotificationManager
|
||||
get() = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
/** What the last [sync] saw, so an unchanged provider costs no channel writes. */
|
||||
@Volatile
|
||||
private var lastSync: Any? = null
|
||||
|
||||
/**
|
||||
* Bring the channels in line with the provider's calendars. An empty
|
||||
* snapshot changes nothing: that is a provider hiccup far more often than
|
||||
* every calendar gone at once.
|
||||
*/
|
||||
@Synchronized
|
||||
fun sync() {
|
||||
val calendars = calendarDataSource.calendars()
|
||||
if (calendars.isEmpty()) return
|
||||
val manager = manager
|
||||
val existing = manager.notificationChannels
|
||||
val existingIds = existing.mapTo(HashSet()) { it.id }
|
||||
if (syncKey(calendars, existingIds) == lastSync && LEGACY_REMINDER_CHANNEL_ID !in existingIds) return
|
||||
val groups = manager.notificationChannelGroups
|
||||
val plan = plan(calendars, existing, groups.map { it.id })
|
||||
plan.staleChannelIds.forEach(manager::deleteNotificationChannel)
|
||||
plan.staleGroupIds.forEach(manager::deleteNotificationChannelGroup)
|
||||
val groupNames = groups.associate { it.id to it.name?.toString() }
|
||||
write(
|
||||
groups = plan.groupsInUse.filter { groupNames[it.id] != groupLabel(it) },
|
||||
channels = plan.channelsToWrite,
|
||||
existing = existing,
|
||||
)
|
||||
if (RING_CHANNEL_ID in existingIds) writeRingChannel()
|
||||
val ids = existingIds - plan.staleChannelIds.toSet() + plan.channelsToWrite.map { it.id }
|
||||
lastSync = syncKey(calendars, if (retireLegacyChannel()) ids - LEGACY_REMINDER_CHANNEL_ID else ids)
|
||||
}
|
||||
|
||||
private fun syncKey(calendars: List<CalendarSource>, channelIds: Set<String>): Any = listOf(
|
||||
calendars.map { listOf(it.id, it.displayName, it.accountName, it.accountType, it.isLocal, it.syncsEvents) },
|
||||
Locale.getDefault(),
|
||||
channelIds,
|
||||
)
|
||||
|
||||
/**
|
||||
* The channel to post [calendarId]'s reminder on, created first if a sync
|
||||
* has not got to it yet. Null when the calendar no longer exists. Should the
|
||||
* calendar lookup fail or come back empty, the channel is still created,
|
||||
* plainly named and ungrouped, for the next sync to name and file — a
|
||||
* reminder is never lost to its channel. A ringing [style] posts on the
|
||||
* shared ring channel instead, once the calendar is known to exist.
|
||||
*/
|
||||
@Synchronized
|
||||
fun channelFor(calendarId: Long, style: ReminderAlertStyle = ReminderAlertStyle.Notification): String? {
|
||||
val calendarChannel = calendarChannelFor(calendarId) ?: return null
|
||||
return if (style == ReminderAlertStyle.FullScreenRing) ringChannel() else calendarChannel
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel a ringing full-screen alert (#171) posts on, shared by every
|
||||
* calendar. Silent: the ring service owns the sound and vibration, so the
|
||||
* channel adds neither. High importance keeps the heads-up a denied
|
||||
* full-screen intent degrades to.
|
||||
*/
|
||||
private fun ringChannel(): String {
|
||||
val manager = manager
|
||||
if (manager.getNotificationChannel(RING_CHANNEL_ID) == null) writeRingChannel()
|
||||
return RING_CHANNEL_ID
|
||||
}
|
||||
|
||||
/** Creates the ring channel, or renames it after a language change. */
|
||||
private fun writeRingChannel() {
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
RING_CHANNEL_ID,
|
||||
context.getString(R.string.reminder_ring_channel_name),
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = context.getString(R.string.reminder_ring_channel_description)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a ring may sound for [calendarId]: neither its own channel, its
|
||||
* account's group nor the ring channel is blocked, since the service plays
|
||||
* the sound itself.
|
||||
*/
|
||||
fun canRing(calendarId: Long): Boolean {
|
||||
val manager = manager
|
||||
return listOf(reminderChannelId(calendarId), RING_CHANNEL_ID).none { id ->
|
||||
val channel = manager.getNotificationChannel(id) ?: return@none false
|
||||
channel.importance == NotificationManager.IMPORTANCE_NONE ||
|
||||
channel.group?.let { manager.getNotificationChannelGroup(it)?.isBlocked } == true
|
||||
}
|
||||
}
|
||||
|
||||
private fun calendarChannelFor(calendarId: Long): String? {
|
||||
val id = reminderChannelId(calendarId)
|
||||
val manager = manager
|
||||
if (manager.getNotificationChannel(id) != null) return id
|
||||
val existing = manager.notificationChannels
|
||||
val calendars = try {
|
||||
calendarDataSource.calendars()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not look up calendar $calendarId for its channel", e)
|
||||
emptyList()
|
||||
}
|
||||
if (calendars.isEmpty()) {
|
||||
val fallback = ReminderChannelSpec(id, calendarId, context.getString(R.string.reminder_channel_name), null)
|
||||
write(emptyList(), listOf(fallback), existing)
|
||||
return id
|
||||
}
|
||||
val plan = plan(calendars, existing, emptyList())
|
||||
val spec = plan.channels.firstOrNull { it.calendarId == calendarId } ?: return null
|
||||
val knownGroups = manager.notificationChannelGroups.mapTo(HashSet()) { it.id }
|
||||
write(
|
||||
groups = plan.groups.filter { it.id == spec.groupId && it.id !in knownGroups },
|
||||
channels = listOf(spec),
|
||||
existing = existing,
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
private fun plan(
|
||||
calendars: List<CalendarSource>,
|
||||
existing: List<NotificationChannel>,
|
||||
existingGroupIds: List<String>,
|
||||
): ReminderChannelPlan = planReminderChannels(
|
||||
calendars = calendars,
|
||||
existing = existing.map { ExistingReminderChannel(it.id, it.name.toString(), it.description, it.group) },
|
||||
existingGroupIds = existingGroupIds,
|
||||
unnamed = context.getString(R.string.calendar_unnamed),
|
||||
description = context.getString(R.string.reminder_channel_description),
|
||||
)
|
||||
|
||||
private fun write(
|
||||
groups: List<ReminderChannelGroupSpec>,
|
||||
channels: List<ReminderChannelSpec>,
|
||||
existing: List<NotificationChannel>,
|
||||
) {
|
||||
val manager = manager
|
||||
if (groups.isNotEmpty()) {
|
||||
manager.createNotificationChannelGroups(groups.map { NotificationChannelGroup(it.id, groupLabel(it)) })
|
||||
}
|
||||
if (channels.isEmpty()) return
|
||||
val byId = existing.associateBy { it.id }
|
||||
val template = byId[LEGACY_REMINDER_CHANNEL_ID]
|
||||
// Only a channel's first creation sets its behaviour; later calls just
|
||||
// rename it, so an existing one passes its own importance back.
|
||||
manager.createNotificationChannels(
|
||||
channels.map { spec ->
|
||||
val current = byId[spec.id]
|
||||
NotificationChannel(
|
||||
spec.id,
|
||||
spec.name,
|
||||
current?.importance ?: template?.importance ?: NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = context.getString(R.string.reminder_channel_description)
|
||||
spec.groupId?.let { group = it }
|
||||
if (current == null && template != null) inheritFrom(template)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry the old single channel's sound, vibration and lock-screen choices
|
||||
* over to a new per-calendar one, so an upgrade keeps how reminders sounded.
|
||||
*/
|
||||
private fun NotificationChannel.inheritFrom(template: NotificationChannel) {
|
||||
setSound(template.sound, template.audioAttributes)
|
||||
// The pattern setter also flips vibration on or off, so it goes first.
|
||||
vibrationPattern = template.vibrationPattern
|
||||
enableVibration(template.shouldVibrate())
|
||||
enableLights(template.shouldShowLights())
|
||||
lightColor = template.lightColor
|
||||
lockscreenVisibility = template.lockscreenVisibility
|
||||
setShowBadge(template.canShowBadge())
|
||||
// Only honoured with Do Not Disturb access, so usually left to the user.
|
||||
setBypassDnd(template.canBypassDnd())
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the pre-#145 channel once the per-calendar ones carry its settings,
|
||||
* but not while a reminder posted on it is still on screen — deleting the
|
||||
* channel would take that notification with it. Returns whether it is gone.
|
||||
*/
|
||||
private fun retireLegacyChannel(): Boolean {
|
||||
val manager = manager
|
||||
if (manager.getNotificationChannel(LEGACY_REMINDER_CHANNEL_ID) == null) return true
|
||||
val showing = runCatching {
|
||||
manager.activeNotifications.any { it.notification.channelId == LEGACY_REMINDER_CHANNEL_ID }
|
||||
}.getOrElse { e ->
|
||||
Log.w(TAG, "Could not read active notifications", e)
|
||||
true
|
||||
}
|
||||
if (!showing) manager.deleteNotificationChannel(LEGACY_REMINDER_CHANNEL_ID)
|
||||
return !showing
|
||||
}
|
||||
|
||||
private fun groupLabel(group: ReminderChannelGroupSpec): String {
|
||||
if (group.isLocal) return context.getString(R.string.calendars_local_header)
|
||||
val name = group.label.ifBlank { context.getString(R.string.calendar_unnamed) }
|
||||
if (!group.ambiguous) return name
|
||||
return context.getString(R.string.calendars_account_from_source, name, sourceAppLabel(context, group.accountType))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderChannels"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
@@ -18,7 +17,11 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.alertStyleFor
|
||||
import de.jeanlucmakiola.calendula.ui.reminders.ReminderAlertActivity
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.ring.canUseFullScreenIntent
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import java.time.DayOfWeek
|
||||
@@ -29,8 +32,9 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Posts one notification per occurrence on a dedicated channel. Tapping opens
|
||||
* the event's detail screen.
|
||||
* Posts one notification per occurrence on its calendar's channel
|
||||
* ([ReminderChannels], #145), or as a full-screen alert where the calendar
|
||||
* asks for one (#171). Tapping opens the event's detail screen.
|
||||
*
|
||||
* The tag is the [occurrenceKey], so each reminder for an occurrence replaces
|
||||
* the one before it in place (#463). A different reminder alerts afresh; the
|
||||
@@ -43,6 +47,8 @@ class ReminderNotifier @Inject constructor(
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val channels: ReminderChannels,
|
||||
private val alerts: FullScreenAlerts,
|
||||
) {
|
||||
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
@@ -53,20 +59,85 @@ class ReminderNotifier @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* The single choke point for "this calendar is switched off", covering the
|
||||
* two paths that reach [post] around the scan's own filter: a snooze armed
|
||||
* before the switch-off, and a read-only install whose switch lives in
|
||||
* [CalendarPrefs].
|
||||
* The single choke point for "this calendar posts no reminders": switched
|
||||
* off (also covering a snooze armed before the switch-off, and a read-only
|
||||
* install whose switch lives in [CalendarPrefs]), or its notifications
|
||||
* turned off in Calendula's own settings (#272).
|
||||
*/
|
||||
private suspend fun isSilenced(calendarId: Long): Boolean =
|
||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||
calendarId in settingsPrefs.mutedReminderCalendarIds.first() ||
|
||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||
calendarDataSource.isCalendarVisible(calendarId) == false
|
||||
|
||||
/** Post [alert], unless its calendar is switched off. */
|
||||
suspend fun post(alert: ReminderAlert) {
|
||||
/**
|
||||
* Post [alert] in its calendar's alert style (#171), unless the calendar is
|
||||
* silenced or gone. [ring] false posts a ringing style as a full-screen
|
||||
* alert that sounds once instead; [quiet] posts a plain notification that
|
||||
* makes no sound. Both are how a ring hands a reminder back to the shade.
|
||||
*/
|
||||
suspend fun post(alert: ReminderAlert, quiet: Boolean = false, ring: Boolean = true) {
|
||||
if (isSilenced(alert.calendarId)) return
|
||||
ensureChannel()
|
||||
val style = when {
|
||||
quiet -> ReminderAlertStyle.Notification
|
||||
else -> effectiveStyle(alert).let {
|
||||
if (it == ReminderAlertStyle.FullScreenRing && !ring) ReminderAlertStyle.FullScreen else it
|
||||
}
|
||||
}
|
||||
// Rung already and handed back to the shade: a catch-up post leaves it there.
|
||||
if (style == ReminderAlertStyle.FullScreenRing && (isShowing(alert) || alerts.isPending(alert))) return
|
||||
val channelId = channelFor(alert, style) ?: return
|
||||
val title = displayTitle(alert)
|
||||
val text = contentText(alert)
|
||||
if (style == ReminderAlertStyle.FullScreenRing) {
|
||||
if (ReminderRingService.start(context, alerts, alert, build(alert, channelId, title, text, style, quiet = false))) {
|
||||
// An earlier reminder for this occurrence gives way to the ring.
|
||||
NotificationManagerCompat.from(context).cancel(alert.occurrenceKey.toString(), NOTIFICATION_ID)
|
||||
cancelLegacy(alert)
|
||||
return
|
||||
}
|
||||
// Android refused the ring: still full-screen, sounding once on the calendar's channel.
|
||||
stopRing(RingMatch.Occurrence(alert.occurrenceKey))
|
||||
val fallback = channelFor(alert, ReminderAlertStyle.FullScreen) ?: return
|
||||
notify(alert, build(alert, fallback, title, text, ReminderAlertStyle.FullScreen, quiet = false))
|
||||
return
|
||||
}
|
||||
// A fresh reminder replaces a ring for its occurrence from before the style changed.
|
||||
if (ring) stopRing(RingMatch.Occurrence(alert.occurrenceKey))
|
||||
notify(alert, build(alert, channelId, title, text, style, quiet))
|
||||
}
|
||||
|
||||
/** Whether a ring for [alert] should go on, now that settings may have changed since it started. */
|
||||
suspend fun ringVerdict(alert: ReminderAlert): RingVerdict = when {
|
||||
!settingsPrefs.remindersEnabled.first() || !canPost() || isSilenced(alert.calendarId) -> RingVerdict.Drop
|
||||
effectiveStyle(alert) != ReminderAlertStyle.FullScreenRing -> RingVerdict.HandBack
|
||||
else -> RingVerdict.Ring
|
||||
}
|
||||
|
||||
/** Whether a live ring for [alert] may go on: its notification can still be seen. */
|
||||
fun canStillRing(alert: ReminderAlert): Boolean = canPost() && channels.canRing(alert.calendarId)
|
||||
|
||||
/** The calendar's style, short of ringing where the user blocked its calendar, group or the ring channel. */
|
||||
private suspend fun effectiveStyle(alert: ReminderAlert): ReminderAlertStyle {
|
||||
val chosen = settingsPrefs.reminderAlertStyles.first().alertStyleFor(alert.calendarId)
|
||||
return if (chosen == ReminderAlertStyle.FullScreenRing && !channels.canRing(alert.calendarId)) {
|
||||
ReminderAlertStyle.FullScreen
|
||||
} else {
|
||||
chosen
|
||||
}
|
||||
}
|
||||
|
||||
private fun channelFor(alert: ReminderAlert, style: ReminderAlertStyle): String? = try {
|
||||
channels.channelFor(alert.calendarId, style)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "No channel for the reminder of event ${alert.eventId}", e)
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun contentText(alert: ReminderAlert): String =
|
||||
listOfNotNull(timeText(alert), alert.location).joinToString(" · ")
|
||||
|
||||
/** When [alert]'s occurrence happens, as its notification words it. */
|
||||
suspend fun timeText(alert: ReminderAlert): String {
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val zone = ZoneId.systemDefault()
|
||||
@@ -74,7 +145,7 @@ class ReminderNotifier @Inject constructor(
|
||||
// resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by
|
||||
// its shared ISO number (1..7) for the date math in reminderTimeText.
|
||||
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
|
||||
val time = reminderTimeText(
|
||||
return reminderTimeText(
|
||||
beginMillis = alert.beginMillis,
|
||||
endMillis = alert.endMillis,
|
||||
isAllDay = alert.isAllDay,
|
||||
@@ -87,19 +158,35 @@ class ReminderNotifier @Inject constructor(
|
||||
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||
)
|
||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
}
|
||||
|
||||
/**
|
||||
* The reminder's notification in [style]. A ringing one belongs to
|
||||
* [ReminderRingService]: ongoing and opening the alert screen. A full-screen
|
||||
* one adds the alert screen as a full-screen intent where Android allows
|
||||
* it, degrading to a heads-up where it does not, and is dismissed by a
|
||||
* swipe, so its screen closes too.
|
||||
*/
|
||||
private fun build(
|
||||
alert: ReminderAlert,
|
||||
channelId: String,
|
||||
title: String,
|
||||
text: String,
|
||||
style: ReminderAlertStyle,
|
||||
quiet: Boolean,
|
||||
): Notification {
|
||||
val ringing = style == ReminderAlertStyle.FullScreenRing
|
||||
val fullScreen = style.isFullScreen
|
||||
val builder = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setWhen(alert.beginMillis)
|
||||
.setShowWhen(false)
|
||||
.setCategory(NotificationCompat.CATEGORY_EVENT)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(shownKey(alert) == alert.key)
|
||||
.setCategory(if (ringing) NotificationCompat.CATEGORY_ALARM else NotificationCompat.CATEGORY_EVENT)
|
||||
.setPriority(if (ringing) NotificationCompat.PRIORITY_MAX else NotificationCompat.PRIORITY_HIGH)
|
||||
.addExtras(Bundle().apply { putLong(EXTRA_REMINDER_KEY, alert.key) })
|
||||
.setContentIntent(detailIntent(alert))
|
||||
.setContentIntent(if (ringing) alertScreenIntent(alert) else detailIntent(alert))
|
||||
.addAction(
|
||||
R.drawable.ic_notification_snooze,
|
||||
context.getString(R.string.reminder_action_snooze),
|
||||
@@ -110,7 +197,18 @@ class ReminderNotifier @Inject constructor(
|
||||
context.getString(R.string.reminder_action_dismiss),
|
||||
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
|
||||
)
|
||||
.build()
|
||||
if (fullScreen) builder.setDeleteIntent(actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS))
|
||||
if (ringing) {
|
||||
builder.setOngoing(true).setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
} else {
|
||||
builder.setAutoCancel(true).setOnlyAlertOnce(shownKey(alert) == alert.key)
|
||||
}
|
||||
if (quiet) builder.setSilent(true)
|
||||
if (fullScreen && context.canUseFullScreenIntent()) builder.setFullScreenIntent(alertScreenIntent(alert), true)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun notify(alert: ReminderAlert, notification: Notification) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
cancelLegacy(alert)
|
||||
try {
|
||||
@@ -131,10 +229,25 @@ class ReminderNotifier @Inject constructor(
|
||||
if (shownKey(alert) == alert.key) manager.cancel(alert.occurrenceKey.toString(), NOTIFICATION_ID)
|
||||
// Posted before reminders shared a notification, under the reminder's own key.
|
||||
manager.cancel(alert.key.toString(), NOTIFICATION_ID)
|
||||
stopRing(RingMatch.Key(alert.key))
|
||||
alerts.markHandled(alert)
|
||||
}
|
||||
|
||||
/** Stop the ring if it is for a reminder [match]es; [handBack] keeps it in the shade, quietly. */
|
||||
fun stopRing(match: RingMatch, handBack: Boolean = false) {
|
||||
if (alerts.mayRing(match)) ReminderRingService.stop(context, alerts, match, handBack)
|
||||
}
|
||||
|
||||
/** Whether a different reminder for [alert]'s occurrence is on screen now. */
|
||||
fun isSuperseded(alert: ReminderAlert): Boolean = shownKey(alert).let { it != null && it != alert.key }
|
||||
fun isSuperseded(alert: ReminderAlert): Boolean {
|
||||
val ringing = alerts.ringing?.takeIf { it.occurrenceKey == alert.occurrenceKey }
|
||||
if (ringing != null) return ringing.key != alert.key
|
||||
return shownKey(alert).let { it != null && it != alert.key }
|
||||
}
|
||||
|
||||
/** Whether [alert] itself is still on screen, as a notification or ringing. */
|
||||
fun isShowing(alert: ReminderAlert): Boolean =
|
||||
alerts.ringing?.key == alert.key || shownKey(alert) == alert.key
|
||||
|
||||
/** The key of the reminder showing for [alert]'s occurrence, if any. */
|
||||
private fun shownKey(alert: ReminderAlert): Long? {
|
||||
@@ -153,7 +266,7 @@ class ReminderNotifier @Inject constructor(
|
||||
private fun cancelLegacy(alert: ReminderAlert) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
manager.activeNotifications
|
||||
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == CHANNEL_ID }
|
||||
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == LEGACY_REMINDER_CHANNEL_ID }
|
||||
.filter { !it.notification.extras.containsKey(EXTRA_REMINDER_KEY) }
|
||||
.filter { it.notification.`when` == alert.beginMillis }
|
||||
.filter { it.notification.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString() == displayTitle(alert) }
|
||||
@@ -171,6 +284,13 @@ class ReminderNotifier @Inject constructor(
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun alertScreenIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_ALERT_SCREEN),
|
||||
ReminderAlertActivity.intent(context, alert),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
// Shares the per-(alert, action) request-code scheme with the buttons.
|
||||
@@ -181,21 +301,8 @@ class ReminderNotifier @Inject constructor(
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
/** Channel creation is idempotent; re-running refreshes the localized name. */
|
||||
private fun ensureChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.reminder_channel_name),
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = context.getString(R.string.reminder_channel_description)
|
||||
}
|
||||
NotificationManagerCompat.from(context).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderNotifier"
|
||||
const val CHANNEL_ID = "reminders"
|
||||
|
||||
// One id, distinct tags: the tag (occurrence) already keys the
|
||||
// notification, so a fixed id keeps cancellation/replacement simple.
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.RingtoneManager
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.IntentCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderActionReceiver.Companion.putAlert
|
||||
import de.jeanlucmakiola.floret.ring.RingVibrator
|
||||
import de.jeanlucmakiola.floret.ring.RingWakeLock
|
||||
import de.jeanlucmakiola.floret.ring.TonePlayer
|
||||
import de.jeanlucmakiola.floret.ring.isInCall
|
||||
import de.jeanlucmakiola.floret.ring.startRingForeground
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Rings a reminder whose calendar asks for "Ring until dismissed" (#171): the
|
||||
* alarm sound looped with vibration, for as long as the reminder is neither
|
||||
* dismissed nor snoozed, up to [RING_TIMEOUT_MILLIS]. Then it goes quiet and
|
||||
* hands the reminder back to the shade as an ordinary notification.
|
||||
*
|
||||
* One reminder rings at a time. A new one takes over, and the one it replaces
|
||||
* goes back to the shade, silently, unless it was for the same occurrence.
|
||||
* Starts and stops both arrive as commands, so they are handled on the main
|
||||
* thread in the order they were sent.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderRingService : Service() {
|
||||
|
||||
@Inject lateinit var alerts: FullScreenAlerts
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
|
||||
@Inject @ApplicationScope
|
||||
lateinit var appScope: CoroutineScope
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private val tones by lazy { TonePlayer(this) }
|
||||
private val vibrator by lazy { RingVibrator(this) }
|
||||
private val wakeLock by lazy { RingWakeLock(this, WAKE_LOCK_TAG, RING_TIMEOUT_MILLIS) }
|
||||
|
||||
private var ringing: Job? = null
|
||||
private var timeout: Job? = null
|
||||
private var notificationId = RING_NOTIFICATION_ID
|
||||
private var lastStartId: Int? = null
|
||||
|
||||
/** The start the system would redeliver, so a takeover can mark it done. */
|
||||
private var ringStartId: Int? = null
|
||||
|
||||
/** Whether the ring's players were ever built, so a stop-only service builds none. */
|
||||
private var rang = false
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
lastStartId = startId
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
stop(intent)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
val alert = intent?.let(ReminderActionReceiver::alertFrom)
|
||||
if (intent == null || alert == null) {
|
||||
stopIfIdle()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
try {
|
||||
return startRing(intent, alert, startId)
|
||||
} finally {
|
||||
// Only now, once alerts.ringing says whether it rings, so a stop sent meanwhile is never skipped.
|
||||
if (flags and START_FLAG_REDELIVERY == 0) alerts.removePending(alert)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRing(intent: Intent, alert: ReminderAlert, startId: Int): Int {
|
||||
val notification = IntentCompat.getParcelableExtra(intent, EXTRA_NOTIFICATION, Notification::class.java)
|
||||
if (notification == null) {
|
||||
stopIfIdle()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
val previous = alerts.ringing
|
||||
// A different reminder posts under the other id, so it is a new
|
||||
// notification whose full-screen intent opens; an update would not.
|
||||
val replacing = previous != null && previous.key != alert.key
|
||||
val shown = notificationId
|
||||
if (replacing) notificationId = if (shown == RING_NOTIFICATION_ID) OTHER_RING_NOTIFICATION_ID else RING_NOTIFICATION_ID
|
||||
val foreground = runCatching { startRingForeground(notificationId, notification) }
|
||||
if (foreground.isFailure) {
|
||||
// A restart after a process death may be refused a foreground start:
|
||||
// the reminder still shows full-screen, sounding once.
|
||||
Log.w(TAG, "Could not ring for event ${alert.eventId}", foreground.exceptionOrNull())
|
||||
notificationId = shown
|
||||
handBack(alert, quiet = false)
|
||||
stopIfIdle()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
if (replacing) NotificationManagerCompat.from(this).cancel(shown)
|
||||
// A duplicate start for this very ring leaves the original in charge of redelivery.
|
||||
if (previous?.key == alert.key) return START_NOT_STICKY
|
||||
// Only the newest start is redelivered after a process death; the older
|
||||
// ones are done, so a handled reminder never rings again.
|
||||
ringStartId?.let { stopSelfResult(it) }
|
||||
ringStartId = startId
|
||||
alerts.ringing = alert
|
||||
if (previous != null) {
|
||||
// Its screen closes either way; another occurrence goes back to the shade.
|
||||
if (previous.occurrenceKey != alert.occurrenceKey) handBack(previous) else alerts.markHandled(previous)
|
||||
}
|
||||
val since = intent.getLongExtra(EXTRA_SINCE, System.currentTimeMillis())
|
||||
ring(alert, remaining = since + RING_TIMEOUT_MILLIS - System.currentTimeMillis())
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (rang) releaseRing()
|
||||
// Stopped from outside: its screen must not stay up for a ring that is gone.
|
||||
alerts.ringing?.let { if (alerts.handBackOnDestroy) handBack(it) else alerts.markHandled(it) }
|
||||
alerts.handBackOnDestroy = false
|
||||
alerts.ringing = null
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/** Rings for what is left of the ring's time, so a ring restarted after a process death does not begin again. */
|
||||
private fun ring(alert: ReminderAlert, remaining: Long) {
|
||||
releaseRing()
|
||||
rang = true
|
||||
wakeLock.acquire()
|
||||
timeout = scope.launch {
|
||||
delay(remaining.coerceAtLeast(0L))
|
||||
giveUp()
|
||||
}
|
||||
ringing = scope.launch {
|
||||
// Settings changed while this ring was on its way apply before it makes a sound.
|
||||
val verdict = try {
|
||||
withContext(Dispatchers.IO) { notifier.ringVerdict(alert) }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not re-check the ring for event ${alert.eventId}", e)
|
||||
RingVerdict.Ring
|
||||
}
|
||||
if (verdict != RingVerdict.Ring) {
|
||||
// Never heard yet, so it alerts once in its new style.
|
||||
if (verdict == RingVerdict.HandBack) handBack(alert, quiet = false)
|
||||
end()
|
||||
return@launch
|
||||
}
|
||||
sound(alert)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tone and vibration, paused for a phone call and back after it, until the
|
||||
* user hides the ring's notification, which would leave no way to stop it.
|
||||
*/
|
||||
private suspend fun sound(alert: ReminderAlert) {
|
||||
var sounding = false
|
||||
var tick = 0
|
||||
while (true) {
|
||||
if (tick++ % VISIBILITY_CHECK_TICKS == 0 && !notifier.canStillRing(alert)) {
|
||||
handBack(alert)
|
||||
end()
|
||||
return
|
||||
}
|
||||
val inCall = isInCall()
|
||||
if (inCall && sounding) {
|
||||
tones.stop()
|
||||
vibrator.stop()
|
||||
sounding = false
|
||||
} else if (!inCall && !sounding) {
|
||||
vibrator.start()
|
||||
tones.play(scope, ringSources())
|
||||
sounding = true
|
||||
}
|
||||
delay(CALL_CHECK_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stop(intent: Intent) {
|
||||
val current = alerts.ringing
|
||||
if (current != null && matchFrom(intent).matches(current)) {
|
||||
if (intent.getBooleanExtra(EXTRA_HAND_BACK, false)) handBack(current)
|
||||
end()
|
||||
} else {
|
||||
stopIfIdle()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rung long enough: back to the shade quietly. */
|
||||
private fun giveUp() {
|
||||
timeout = null
|
||||
alerts.ringing?.let(::handBack)
|
||||
end()
|
||||
}
|
||||
|
||||
/** Ends the ring, closes its screen, and stops the service unless a newer command is on its way. */
|
||||
private fun end() {
|
||||
alerts.ringing?.let(alerts::markHandled)
|
||||
releaseRing()
|
||||
alerts.ringing = null
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopIfIdle()
|
||||
}
|
||||
|
||||
/** Declines while a newer command is still on its way. */
|
||||
private fun stopIfIdle() {
|
||||
if (alerts.ringing == null) lastStartId?.let { stopSelfResult(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* [alert] back in the shade, in the app's scope so a service stopped
|
||||
* meanwhile cannot cancel it, and its alert screen closed. A newer reminder
|
||||
* for the occurrence that took its place meanwhile is left alone.
|
||||
*/
|
||||
private fun handBack(alert: ReminderAlert, quiet: Boolean = true) {
|
||||
alerts.markHandled(alert)
|
||||
appScope.launch {
|
||||
try {
|
||||
if (!notifier.isSuperseded(alert)) notifier.post(alert, quiet = quiet, ring = false)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not hand the reminder of event ${alert.eventId} back", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseRing() {
|
||||
ringing?.cancel()
|
||||
ringing = null
|
||||
timeout?.cancel()
|
||||
timeout = null
|
||||
tones.stop()
|
||||
vibrator.stop()
|
||||
wakeLock.release()
|
||||
}
|
||||
|
||||
/** The device's alarm sound; "None", or none opening, leaves the vibration. */
|
||||
private fun ringSources() = listOfNotNull(
|
||||
RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_ALARM),
|
||||
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM),
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ReminderRingService"
|
||||
private const val ACTION_RING = "de.jeanlucmakiola.calendula.reminders.RING"
|
||||
private const val ACTION_STOP = "de.jeanlucmakiola.calendula.reminders.RING_STOP"
|
||||
private const val EXTRA_NOTIFICATION = "notification"
|
||||
private const val EXTRA_SINCE = "since"
|
||||
private const val CALL_CHECK_MILLIS = 1_000L
|
||||
|
||||
/** How many call checks between re-reading whether the ring's notification can still be seen. */
|
||||
private const val VISIBILITY_CHECK_TICKS = 5
|
||||
private const val EXTRA_MATCH_KIND = "match_kind"
|
||||
private const val EXTRA_MATCH_VALUE = "match_value"
|
||||
private const val EXTRA_HAND_BACK = "hand_back"
|
||||
private const val WAKE_LOCK_TAG = "calendula:reminder-ring"
|
||||
|
||||
/** Clear of the reminders' tagged id 1 and the backup notice's 2. */
|
||||
private const val RING_NOTIFICATION_ID = 171
|
||||
private const val OTHER_RING_NOTIFICATION_ID = 172
|
||||
|
||||
/** How long a reminder rings unanswered before it goes quiet. */
|
||||
const val RING_TIMEOUT_MILLIS = 10L * 60 * 1000
|
||||
|
||||
/**
|
||||
* Rings [alert] under [notification]. False when Android refused to
|
||||
* start the service from where the reminder fired.
|
||||
*/
|
||||
fun start(context: Context, alerts: FullScreenAlerts, alert: ReminderAlert, notification: Notification): Boolean {
|
||||
val intent = Intent(context, ReminderRingService::class.java)
|
||||
.setAction(ACTION_RING)
|
||||
.putAlert(alert)
|
||||
.putExtra(EXTRA_NOTIFICATION, notification)
|
||||
.putExtra(EXTRA_SINCE, System.currentTimeMillis())
|
||||
alerts.addPending(alert)
|
||||
return try {
|
||||
RingWakeLock.handOff(context, WAKE_LOCK_TAG)
|
||||
context.startForegroundService(intent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not start the ring for event ${alert.eventId}", e)
|
||||
alerts.removePending(alert)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the ring if it is for a reminder [match]es, in order with the
|
||||
* starts sent before it. [handBack] puts it back in the shade quietly.
|
||||
* A command rather than `stopService`, which would also drop a start
|
||||
* still on its way; sent while the ring's foreground service keeps the
|
||||
* app allowed to.
|
||||
*/
|
||||
fun stop(context: Context, alerts: FullScreenAlerts, match: RingMatch, handBack: Boolean = false) {
|
||||
val (kind, value) = when (match) {
|
||||
is RingMatch.Key -> 0 to match.key
|
||||
is RingMatch.Occurrence -> 1 to match.occurrenceKey
|
||||
is RingMatch.Calendar -> 2 to match.calendarId
|
||||
RingMatch.Any -> 3 to 0L
|
||||
}
|
||||
val intent = Intent(context, ReminderRingService::class.java)
|
||||
.setAction(ACTION_STOP)
|
||||
.putExtra(EXTRA_MATCH_KIND, kind)
|
||||
.putExtra(EXTRA_MATCH_VALUE, value)
|
||||
.putExtra(EXTRA_HAND_BACK, handBack)
|
||||
try {
|
||||
context.startService(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not stop the ring", e)
|
||||
// Refused from the background: nothing must ring on regardless,
|
||||
// unless a start is still on its way, which this would crash.
|
||||
if (!alerts.anyPending) {
|
||||
alerts.handBackOnDestroy = handBack
|
||||
context.stopService(Intent(context, ReminderRingService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchFrom(intent: Intent): RingMatch {
|
||||
val value = intent.getLongExtra(EXTRA_MATCH_VALUE, 0L)
|
||||
return when (intent.getIntExtra(EXTRA_MATCH_KIND, 3)) {
|
||||
0 -> RingMatch.Key(value)
|
||||
1 -> RingMatch.Occurrence(value)
|
||||
2 -> RingMatch.Calendar(value)
|
||||
else -> RingMatch.Any
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ class ReminderScanner @Inject constructor(
|
||||
private val source: ReminderInstanceSource,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val notifier: ReminderNotifier,
|
||||
private val channels: ReminderChannels,
|
||||
private val snoozes: ReminderSnoozeScheduler,
|
||||
private val alarms: ReminderAlarmScheduler,
|
||||
private val state: ReminderStatePrefs,
|
||||
@@ -85,10 +86,14 @@ class ReminderScanner @Inject constructor(
|
||||
return
|
||||
}
|
||||
|
||||
syncChannels()
|
||||
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
|
||||
// Reach into the past too: an all-day "at time of event" encodes to a
|
||||
// negative offset, and a catch-up pass needs the occurrences it missed.
|
||||
// Calendars muted in-app (#272) plan nothing, so they arm no wake-ups.
|
||||
val muted = settingsPrefs.mutedReminderCalendarIds.first()
|
||||
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
||||
.filter { it.calendarId !in muted }
|
||||
val planned = planReminders(
|
||||
instances = occurrences,
|
||||
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId })
|
||||
@@ -117,6 +122,19 @@ class ReminderScanner @Inject constructor(
|
||||
alarms.scheduleScan(schedule.nextAlarmMillis)
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider change re-scans, so a calendar added, renamed or removed keeps
|
||||
* its channel in step here. Skipped while reminders are off, when the
|
||||
* channels have nothing to do. A failure never costs the reminders.
|
||||
*/
|
||||
private fun syncChannels() {
|
||||
try {
|
||||
channels.sync()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Reminder channel sync failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasReadCalendar(): Boolean = context.hasPermission(Manifest.permission.READ_CALENDAR)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
/** How a calendar's reminders get the user's attention (#171). */
|
||||
enum class ReminderAlertStyle(val storageValue: String) {
|
||||
/** An ordinary notification on the calendar's channel. */
|
||||
Notification("notification"),
|
||||
|
||||
/** A full-screen alert that sounds the calendar's notification sound once and stays until handled. */
|
||||
FullScreen("full_screen"),
|
||||
|
||||
/** A full-screen alert that rings and vibrates like an alarm until handled. */
|
||||
FullScreenRing("full_screen_ring"),
|
||||
;
|
||||
|
||||
val isFullScreen: Boolean get() = this != Notification
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String): ReminderAlertStyle? = entries.firstOrNull { it.storageValue == value }
|
||||
}
|
||||
}
|
||||
|
||||
/** [calendarId]'s alert style; a calendar without one uses plain notifications. */
|
||||
fun Map<Long, ReminderAlertStyle>.alertStyleFor(calendarId: Long): ReminderAlertStyle =
|
||||
this[calendarId] ?: ReminderAlertStyle.Notification
|
||||
|
||||
/**
|
||||
* Parse `id=style` entries joined by `;`. Malformed entries and unknown styles
|
||||
* are dropped, so a garbled value degrades to plain notifications.
|
||||
*/
|
||||
fun parseAlertStyles(stored: String?): Map<Long, ReminderAlertStyle> =
|
||||
stored?.split(";").orEmpty().mapNotNull { entry ->
|
||||
val parts = entry.split("=")
|
||||
if (parts.size != 2) return@mapNotNull null
|
||||
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
|
||||
val style = ReminderAlertStyle.fromStorage(parts[1].trim()) ?: return@mapNotNull null
|
||||
calendarId to style
|
||||
}.toMap()
|
||||
|
||||
/** The stored form of [parseAlertStyles]; plain-notification entries are left out. */
|
||||
fun Map<Long, ReminderAlertStyle>.toStoredAlertStyles(): String =
|
||||
entries.filter { it.value != ReminderAlertStyle.Notification }
|
||||
.sortedBy { it.key }
|
||||
.joinToString(";") { (id, style) -> "$id=${style.storageValue}" }
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -72,11 +73,13 @@ fun accountGroupTitle(group: CalendarAccountGroup): String {
|
||||
@Composable
|
||||
fun sourceAppName(accountType: String): String {
|
||||
val context = LocalContext.current
|
||||
return remember(accountType) {
|
||||
val pm = context.packageManager
|
||||
val packages = sourceAppPackages(context, accountType)
|
||||
packages.firstNotNullOfOrNull { pkg ->
|
||||
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
|
||||
} ?: accountType
|
||||
}
|
||||
return remember(accountType) { sourceAppLabel(context, accountType) }
|
||||
}
|
||||
|
||||
/** [sourceAppName] outside composition, e.g. for notification channel groups. */
|
||||
fun sourceAppLabel(context: Context, accountType: String): String {
|
||||
val pm = context.packageManager
|
||||
return sourceAppPackages(context, accountType).firstNotNullOfOrNull { pkg ->
|
||||
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
|
||||
} ?: accountType
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package de.jeanlucmakiola.calendula.ui.reminders
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.reminders.FullScreenAlerts
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderActionReceiver
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderActionReceiver.Companion.putAlert
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlert
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderRingService
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.isDark
|
||||
import de.jeanlucmakiola.floret.prefs.isPureBlack
|
||||
import de.jeanlucmakiola.floret.ring.showOverLockScreen
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The full-screen reminder alert (#171), opened by the reminder's full-screen
|
||||
* intent over the lock screen, or, for a ringing one, from its heads-up.
|
||||
* It stays until the reminder is dismissed or snoozed — here, from the
|
||||
* notification, or by the ring giving up — and back does not close it.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderAlertActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var alerts: FullScreenAlerts
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
@Inject lateinit var calendarDataSource: CalendarDataSource
|
||||
|
||||
@Inject @IoDispatcher
|
||||
lateinit var io: CoroutineDispatcher
|
||||
|
||||
private var state by mutableStateOf<ReminderAlertUiState>(ReminderAlertUiState.Loading)
|
||||
private var alert: ReminderAlert? = null
|
||||
private var loading: Job? = null
|
||||
private var screenOn: Job? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
showOverLockScreen()
|
||||
enableEdgeToEdge()
|
||||
show(intent)
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
alerts.handled.collect { key -> if (key == alert?.key) finish() }
|
||||
}
|
||||
}
|
||||
val cachedTheme = WindowThemeCache.read(this) ?: ThemeMode.SYSTEM
|
||||
val cachedDynamicColor = WindowThemeCache.readDynamicColor(this) ?: true
|
||||
setContent {
|
||||
val themeMode by settingsPrefs.themeMode.collectAsStateWithLifecycle(cachedTheme)
|
||||
val dynamicColor by settingsPrefs.dynamicColor.collectAsStateWithLifecycle(cachedDynamicColor)
|
||||
val soften by settingsPrefs.softenCalendarColors.collectAsStateWithLifecycle(true)
|
||||
CalendulaTheme(
|
||||
darkTheme = themeMode.isDark(isSystemInDarkTheme()),
|
||||
dynamicColor = dynamicColor,
|
||||
pureBlack = themeMode.isPureBlack,
|
||||
) {
|
||||
CompositionLocalProvider(LocalSoftenColors provides soften) {
|
||||
BackHandler(enabled = true) { /* Handled by Dismiss or Snooze, not by leaving. */ }
|
||||
when (val current = state) {
|
||||
ReminderAlertUiState.Loading ->
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||
is ReminderAlertUiState.Showing -> ReminderAlertScreen(
|
||||
state = current,
|
||||
onSnooze = { act(ReminderActionReceiver.ACTION_SNOOZE) },
|
||||
onDismiss = { act(ReminderActionReceiver.ACTION_DISMISS) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
show(intent)
|
||||
}
|
||||
|
||||
/** Tapped from a stale notification, or handled while this window was away. */
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val current = alert
|
||||
if (current != null && !notifier.isShowing(current)) finish()
|
||||
}
|
||||
|
||||
private fun show(intent: Intent) {
|
||||
val next = ReminderActionReceiver.alertFrom(intent)
|
||||
if (next == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
alert = next
|
||||
// A ring still going, this one's or another's, keeps the screen up as long as it rings.
|
||||
val ringing = alerts.ringing != null
|
||||
keepScreenOn(if (ringing) ReminderRingService.RING_TIMEOUT_MILLIS else ONCE_SCREEN_ON_MILLIS)
|
||||
loading?.cancel()
|
||||
state = ReminderAlertUiState.Loading
|
||||
loading = lifecycleScope.launch {
|
||||
val calendar = try {
|
||||
withContext(io) { calendarDataSource.calendars().firstOrNull { it.id == next.calendarId } }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// The alert still shows, only without its calendar.
|
||||
Log.w(TAG, "Could not read the calendar of event ${next.eventId}", e)
|
||||
null
|
||||
}
|
||||
state = ReminderAlertUiState.Showing(
|
||||
title = next.title,
|
||||
time = notifier.timeText(next),
|
||||
location = next.location?.takeIf { it.isNotBlank() },
|
||||
calendarName = calendar?.displayName,
|
||||
calendarColor = calendar?.color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** An alert nobody answers lets the display sleep again; it stays on the lock screen. */
|
||||
private fun keepScreenOn(millis: Long) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
screenOn?.cancel()
|
||||
screenOn = lifecycleScope.launch {
|
||||
delay(millis)
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
private fun act(action: String) {
|
||||
val current = alert ?: return
|
||||
sendBroadcast(ReminderActionReceiver.intent(this, action, current))
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ReminderAlertActivity"
|
||||
private const val ONCE_SCREEN_ON_MILLIS = 60_000L
|
||||
|
||||
fun intent(context: Context, alert: ReminderAlert): Intent =
|
||||
Intent(context, ReminderAlertActivity::class.java)
|
||||
.setData("calendula://reminder-alert/${alert.key}".toUri())
|
||||
.putAlert(alert)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package de.jeanlucmakiola.calendula.ui.reminders
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Event
|
||||
import androidx.compose.material.icons.outlined.LocationOn
|
||||
import androidx.compose.material.icons.outlined.Snooze
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.ring.RingBadge
|
||||
|
||||
/**
|
||||
* The alert itself, after the incoming-call screen: the calendar's pulsing
|
||||
* badge, what and when in the middle, and the two ways out at the bottom,
|
||||
* large enough to hit half awake.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ReminderAlertScreen(
|
||||
state: ReminderAlertUiState.Showing,
|
||||
onSnooze: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.safeDrawingPadding()
|
||||
.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Centred, but scrolls rather than pushing the buttons off a short landscape screen.
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.heightIn(min = maxHeight),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
state.calendarName?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.size(24.dp))
|
||||
}
|
||||
CalendarBadge(state.calendarColor)
|
||||
Spacer(Modifier.size(32.dp))
|
||||
Text(
|
||||
text = state.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(
|
||||
text = state.time,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
state.location?.let { location ->
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.LocationOn,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text(
|
||||
text = location,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(24.dp))
|
||||
Row(
|
||||
modifier = Modifier.widthIn(max = 480.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
AlertButton(
|
||||
label = stringResource(R.string.reminder_action_snooze),
|
||||
icon = Icons.Outlined.Snooze,
|
||||
tonal = true,
|
||||
onClick = onSnooze,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AlertButton(
|
||||
label = stringResource(R.string.reminder_action_dismiss),
|
||||
icon = Icons.Rounded.Close,
|
||||
tonal = false,
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The calendar's colour as the badge fill, or the theme's primary container without one. */
|
||||
@Composable
|
||||
private fun CalendarBadge(color: Int?) {
|
||||
if (color == null) {
|
||||
RingBadge(icon = Icons.Outlined.Event, size = 120.dp)
|
||||
return
|
||||
}
|
||||
val fill = eventFill(color, LocalFloretDarkTheme.current, LocalSoftenColors.current)
|
||||
RingBadge(
|
||||
icon = Icons.Outlined.Event,
|
||||
containerColor = fill,
|
||||
contentColor = eventInk(fill, alpha = 1f),
|
||||
size = 120.dp,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun AlertButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
tonal: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val height = ButtonDefaults.LargeContainerHeight
|
||||
val content: @Composable () -> Unit = {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(ButtonDefaults.iconSizeFor(height)))
|
||||
Spacer(Modifier.size(ButtonDefaults.iconSpacingFor(height)))
|
||||
Text(label, style = ButtonDefaults.textStyleFor(height), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
val buttonModifier = modifier.heightIn(min = height)
|
||||
val padding = ButtonDefaults.contentPaddingFor(height)
|
||||
if (tonal) {
|
||||
FilledTonalButton(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
shapes = ButtonDefaults.shapesFor(height),
|
||||
contentPadding = padding,
|
||||
) { content() }
|
||||
} else {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
shapes = ButtonDefaults.shapesFor(height),
|
||||
contentPadding = padding,
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula.ui.reminders
|
||||
|
||||
sealed interface ReminderAlertUiState {
|
||||
/** The calendar is still being read; the surface is already up. */
|
||||
data object Loading : ReminderAlertUiState
|
||||
|
||||
data class Showing(
|
||||
/** Raw title; blank shows as "(no title)". */
|
||||
val title: String,
|
||||
val time: String,
|
||||
val location: String?,
|
||||
/** Null when the calendar could not be read. */
|
||||
val calendarName: String?,
|
||||
val calendarColor: Int?,
|
||||
) : ReminderAlertUiState
|
||||
}
|
||||
+229
-56
@@ -14,23 +14,25 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.alertStyleFor
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
@@ -39,17 +41,22 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.android.openNotificationSettings
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.ExpandIcon
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.ring.canUseFullScreenIntent
|
||||
import de.jeanlucmakiola.floret.ring.openFullScreenIntentSettings
|
||||
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalTime
|
||||
|
||||
/**
|
||||
@@ -84,8 +91,15 @@ internal fun NotificationsScreen(
|
||||
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
||||
var showSnooze by remember { mutableStateOf(false) }
|
||||
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
||||
var alertStylePicker by remember { mutableStateOf<Long?>(null) }
|
||||
var alertSoundPicker by remember { mutableStateOf<Long?>(null) }
|
||||
val canFullScreen = rememberCanUseFullScreenIntent()
|
||||
var calendarSectionExpanded by remember { mutableStateOf(false) }
|
||||
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val openCalendarSound: (Long) -> Unit = { calendarId ->
|
||||
scope.launch { context.openNotificationSettings(viewModel.reminderChannelFor(calendarId)) }
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_notifications),
|
||||
@@ -100,6 +114,18 @@ internal fun NotificationsScreen(
|
||||
summary = stringResource(R.string.settings_reminders_hint),
|
||||
position = Position.Top,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_sound),
|
||||
summary = stringResource(R.string.settings_notification_sound_hint),
|
||||
position = Position.Middle,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = {
|
||||
scope.launch {
|
||||
viewModel.syncReminderChannels()
|
||||
context.openNotificationSettings()
|
||||
}
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = reminderChoiceLabel(state.defaultReminderMinutes),
|
||||
@@ -149,10 +175,10 @@ internal fun NotificationsScreen(
|
||||
onClick = { showSnooze = true },
|
||||
)
|
||||
|
||||
// Per-calendar overrides, folded behind one header. Each writable
|
||||
// calendar may keep, drop or replace the global default, separately for
|
||||
// timed and all-day events.
|
||||
if (state.writableCalendars.isNotEmpty()) {
|
||||
// Per-calendar settings, folded behind one header: every switched-on
|
||||
// calendar gets its notification switch and sound (#145, #272); the ones
|
||||
// you can edit also keep, drop or replace the default reminders.
|
||||
if (state.reminderCalendars.isNotEmpty()) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_calendar_reminders_title),
|
||||
@@ -167,30 +193,13 @@ internal fun NotificationsScreen(
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column {
|
||||
state.writableCalendars.forEach { calendar ->
|
||||
state.reminderCalendars.forEach { calendar ->
|
||||
Spacer(Modifier.height(16.dp))
|
||||
// Special-dates calendars own their reminders in their
|
||||
// own section — link there instead.
|
||||
if (calendar.id in state.managedCalendarIds) {
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||
position = Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = onOpenSpecialDates,
|
||||
)
|
||||
return@forEach
|
||||
}
|
||||
val expanded = calendar.id in expandedCalendars
|
||||
val notifying = calendar.id !in state.mutedReminderCalendarIds
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = if (notifying) null else stringResource(R.string.settings_calendar_notifications_off),
|
||||
position = if (expanded) Position.Top else Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = { ExpandIcon(expanded) },
|
||||
@@ -207,22 +216,19 @@ internal fun NotificationsScreen(
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column {
|
||||
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||
position = Position.Middle,
|
||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
||||
)
|
||||
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
|
||||
)
|
||||
}
|
||||
CalendarNotificationRows(
|
||||
calendar = calendar,
|
||||
state = state,
|
||||
notifying = notifying,
|
||||
onSetNotifying = { viewModel.setCalendarRemindersEnabled(calendar.id, it) },
|
||||
onOpenSound = { openCalendarSound(calendar.id) },
|
||||
canFullScreen = canFullScreen,
|
||||
onEditAlertStyle = { alertStylePicker = calendar.id },
|
||||
onEditAlertSound = { alertSoundPicker = calendar.id },
|
||||
onAllowFullScreen = { context.openFullScreenIntentSettings() },
|
||||
onOpenSpecialDates = onOpenSpecialDates,
|
||||
onEditOverride = { overrideDialog = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,6 +236,51 @@ internal fun NotificationsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
alertStylePicker?.let { calendarId ->
|
||||
val current = state.reminderAlertStyles.alertStyleFor(calendarId)
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_alert_style),
|
||||
options = listOf(ReminderAlertStyle.Notification, ReminderAlertStyle.FullScreen),
|
||||
selected = if (current.isFullScreen) ReminderAlertStyle.FullScreen else ReminderAlertStyle.Notification,
|
||||
label = { alertStyleLabel(it) },
|
||||
summary = {
|
||||
stringResource(
|
||||
if (it.isFullScreen) {
|
||||
R.string.settings_alert_style_full_screen_summary
|
||||
} else {
|
||||
R.string.settings_alert_style_notification_summary
|
||||
},
|
||||
)
|
||||
},
|
||||
onSelect = { picked ->
|
||||
if (picked.isFullScreen != current.isFullScreen) {
|
||||
viewModel.setCalendarAlertStyle(calendarId, picked)
|
||||
// Guide the user straight to the switch Android 14+ may hold back.
|
||||
if (picked.isFullScreen && !canFullScreen) context.openFullScreenIntentSettings()
|
||||
}
|
||||
},
|
||||
onDismiss = { alertStylePicker = null },
|
||||
)
|
||||
}
|
||||
alertSoundPicker?.let { calendarId ->
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_alert_sound),
|
||||
options = listOf(ReminderAlertStyle.FullScreen, ReminderAlertStyle.FullScreenRing),
|
||||
selected = state.reminderAlertStyles.alertStyleFor(calendarId),
|
||||
label = { alertSoundLabel(it) },
|
||||
summary = {
|
||||
stringResource(
|
||||
if (it == ReminderAlertStyle.FullScreenRing) {
|
||||
R.string.settings_alert_sound_ring_summary
|
||||
} else {
|
||||
R.string.settings_alert_sound_once_summary
|
||||
},
|
||||
)
|
||||
},
|
||||
onSelect = { viewModel.setCalendarAlertStyle(calendarId, it) },
|
||||
onDismiss = { alertSoundPicker = null },
|
||||
)
|
||||
}
|
||||
if (showSnooze) {
|
||||
DurationPicker(
|
||||
title = stringResource(R.string.settings_snooze_duration),
|
||||
@@ -309,6 +360,116 @@ internal fun NotificationsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One calendar's expanded rows: its notification switch and channel deep-link
|
||||
* for every calendar, then the default-reminder overrides where it takes them —
|
||||
* or, for a special-dates calendar, a link to the section that owns them.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarNotificationRows(
|
||||
calendar: CalendarSource,
|
||||
state: SettingsUiState,
|
||||
notifying: Boolean,
|
||||
onSetNotifying: (Boolean) -> Unit,
|
||||
onOpenSound: () -> Unit,
|
||||
canFullScreen: Boolean,
|
||||
onEditAlertStyle: () -> Unit,
|
||||
onEditAlertSound: () -> Unit,
|
||||
onAllowFullScreen: () -> Unit,
|
||||
onOpenSpecialDates: () -> Unit,
|
||||
onEditOverride: (OverrideTarget) -> Unit,
|
||||
) {
|
||||
val managed = calendar.id in state.managedCalendarIds
|
||||
val takesOverrides = calendar.canModifyContents && !managed
|
||||
val alertStyle = state.reminderAlertStyles.alertStyleFor(calendar.id)
|
||||
val needsPermission = alertStyle.isFullScreen && !canFullScreen
|
||||
Column {
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.settings_calendar_notifications),
|
||||
checked = notifying,
|
||||
onCheckedChange = onSetNotifying,
|
||||
summary = stringResource(R.string.settings_calendar_notifications_hint),
|
||||
position = Position.Middle,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_sound),
|
||||
summary = stringResource(
|
||||
if (alertStyle == ReminderAlertStyle.FullScreenRing) {
|
||||
R.string.settings_calendar_notification_sound_ring_hint
|
||||
} else {
|
||||
R.string.settings_calendar_notification_sound_hint
|
||||
},
|
||||
),
|
||||
position = Position.Middle,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = onOpenSound,
|
||||
)
|
||||
val lastAlertRow = !(managed || takesOverrides)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_alert_style),
|
||||
summary = alertStyleLabel(alertStyle),
|
||||
position = if (lastAlertRow && !alertStyle.isFullScreen) Position.Bottom else Position.Middle,
|
||||
onClick = onEditAlertStyle,
|
||||
)
|
||||
if (alertStyle.isFullScreen) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_alert_sound),
|
||||
summary = alertSoundLabel(alertStyle),
|
||||
position = if (lastAlertRow && !needsPermission) Position.Bottom else Position.Middle,
|
||||
onClick = onEditAlertSound,
|
||||
)
|
||||
}
|
||||
if (needsPermission) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_full_screen_permission),
|
||||
summary = stringResource(R.string.settings_full_screen_permission_hint),
|
||||
position = if (lastAlertRow) Position.Bottom else Position.Middle,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = onAllowFullScreen,
|
||||
)
|
||||
}
|
||||
if (managed) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = onOpenSpecialDates,
|
||||
)
|
||||
} else if (takesOverrides) {
|
||||
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||
position = Position.Middle,
|
||||
onClick = { onEditOverride(OverrideTarget(calendar.id, isAllDay = false)) },
|
||||
)
|
||||
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { onEditOverride(OverrideTarget(calendar.id, isAllDay = true)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExternalLinkIcon() {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||
|
||||
@@ -328,6 +489,20 @@ private fun calendarOverrideSummary(
|
||||
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun alertStyleLabel(style: ReminderAlertStyle): String = stringResource(
|
||||
if (style.isFullScreen) R.string.settings_alert_style_full_screen else R.string.settings_alert_style_notification,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun alertSoundLabel(style: ReminderAlertStyle): String = stringResource(
|
||||
if (style == ReminderAlertStyle.FullScreenRing) R.string.settings_alert_sound_ring else R.string.settings_alert_sound_once,
|
||||
)
|
||||
|
||||
/** Whether Android lets full-screen alerts open, re-read on every `ON_RESUME` like [rememberBatteryOptimizationExempt]. */
|
||||
@Composable
|
||||
private fun rememberCanUseFullScreenIntent(): Boolean = rememberOnResume { it.canUseFullScreenIntent() }
|
||||
|
||||
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
||||
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||
|
||||
@@ -336,20 +511,18 @@ private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||
* `ON_RESUME` so a change made in system settings shows up at once.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberBatteryOptimizationExempt(): Boolean {
|
||||
private fun rememberBatteryOptimizationExempt(): Boolean = rememberOnResume(::isIgnoringBatteryOptimizations)
|
||||
|
||||
/** [read], re-read on every `ON_RESUME` so a change made in system settings shows up at once. */
|
||||
@Composable
|
||||
private fun rememberOnResume(read: (Context) -> Boolean): Boolean {
|
||||
val context = LocalContext.current
|
||||
var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
exempt = isIgnoringBatteryOptimizations(context)
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
var value by remember { mutableStateOf(read(context)) }
|
||||
LifecycleResumeEffect(context) {
|
||||
value = read(context)
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
return exempt
|
||||
return value
|
||||
}
|
||||
|
||||
private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -96,8 +97,14 @@ data class SettingsUiState(
|
||||
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
||||
/** Switched-on calendars you can edit: the per-calendar duration and reminder-override rows. */
|
||||
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||
/** Switched-on calendars, each with its own notification switch and channel (#145, #272). */
|
||||
val reminderCalendars: List<CalendarSource> = emptyList(),
|
||||
/** Calendars whose reminders Calendula doesn't post (#272). */
|
||||
val mutedReminderCalendarIds: Set<Long> = emptySet(),
|
||||
/** Per-calendar alert styles (#171); absent means a plain notification. */
|
||||
val reminderAlertStyles: Map<Long, ReminderAlertStyle> = emptyMap(),
|
||||
/**
|
||||
* Whether the event-colour picker is offered on calendars that publish no
|
||||
* colour palette (the colour may then not survive their next sync).
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.settings
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -33,8 +34,12 @@ import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderChannels
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
|
||||
import de.jeanlucmakiola.calendula.data.reminders.RingMatch
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
@@ -45,6 +50,7 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -67,6 +73,8 @@ class SettingsViewModel @Inject constructor(
|
||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||
private val launcherNameManager: LauncherNameManager,
|
||||
private val reminderScanner: ReminderScanner,
|
||||
private val reminderChannels: ReminderChannels,
|
||||
private val reminderNotifier: ReminderNotifier,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
@@ -74,16 +82,15 @@ class SettingsViewModel @Inject constructor(
|
||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
|
||||
/**
|
||||
* Writable calendars that are switched on — the only ones that take a
|
||||
* per-calendar reminder override, since a switched-off one plans no
|
||||
* reminders at all (#75).
|
||||
* Calendars that are switched on — the only ones that plan reminders at
|
||||
* all (#75), so the only ones with notification settings.
|
||||
*/
|
||||
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
||||
.map { calendars -> calendars.filter { it.canModifyContents && it.isVisibleInSystem } }
|
||||
private val visibleCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
||||
.map { calendars -> calendars.filter { it.isVisibleInSystem } }
|
||||
.catch { emit(emptyList()) }
|
||||
|
||||
val state: StateFlow<SettingsUiState> =
|
||||
combine(prefs.data, writableCalendars) { stored, calendars -> stored.toUiState(calendars) }
|
||||
combine(prefs.data, visibleCalendars) { stored, calendars -> stored.toUiState(calendars) }
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||
@@ -125,7 +132,10 @@ class SettingsViewModel @Inject constructor(
|
||||
snoozeMinutes = this[SettingsKeys.snoozeMinutes],
|
||||
perCalendarReminderOverride = this[SettingsKeys.perCalendarReminderOverride],
|
||||
perCalendarAllDayReminderOverride = this[SettingsKeys.perCalendarAllDayReminderOverride],
|
||||
writableCalendars = calendars,
|
||||
writableCalendars = calendars.filter { it.canModifyContents },
|
||||
reminderCalendars = calendars,
|
||||
mutedReminderCalendarIds = this[SettingsKeys.mutedReminderCalendarIds],
|
||||
reminderAlertStyles = this[SettingsKeys.reminderAlertStyles],
|
||||
allowColorOnUnsupportedCalendars = this[SettingsKeys.allowColorOnUnsupportedCalendars],
|
||||
managedCalendarIds = specialDatesCalendars.values.toSet(),
|
||||
)
|
||||
@@ -353,10 +363,49 @@ class SettingsViewModel @Inject constructor(
|
||||
fun setRemindersEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setRemindersEnabled(enabled)
|
||||
if (!enabled) reminderNotifier.stopRing(RingMatch.Any)
|
||||
reminderScanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-plans, since a muted calendar armed no wake-up for its next reminder. */
|
||||
fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setCalendarRemindersEnabled(calendarId, enabled)
|
||||
if (!enabled) reminderNotifier.stopRing(RingMatch.Calendar(calendarId))
|
||||
reminderScanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCalendarAlertStyle(calendarId: Long, style: ReminderAlertStyle) {
|
||||
viewModelScope.launch {
|
||||
prefs.setCalendarAlertStyle(calendarId, style)
|
||||
// A ring for it goes quiet but stays in the shade.
|
||||
if (style != ReminderAlertStyle.FullScreenRing) {
|
||||
reminderNotifier.stopRing(RingMatch.Calendar(calendarId), handBack = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** [calendarId]'s reminder channel, created if needed so its system settings can open (#340). */
|
||||
suspend fun reminderChannelFor(calendarId: Long): String? = onChannels { channelFor(calendarId) }
|
||||
|
||||
/** Create the per-calendar channels before showing the app's channel list, even with reminders off. */
|
||||
suspend fun syncReminderChannels() {
|
||||
onChannels { sync() }
|
||||
}
|
||||
|
||||
private suspend fun <T> onChannels(block: ReminderChannels.() -> T): T? = withContext(io) {
|
||||
try {
|
||||
reminderChannels.block()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("SettingsViewModel", "Reminder channel update failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutofocusEventTitle(enabled: Boolean) = save { setAutofocusEventTitle(enabled) }
|
||||
|
||||
fun setDefaultEventDuration(minutes: Int) = save { setDefaultEventDurationMinutes(minutes) }
|
||||
|
||||
@@ -376,6 +376,8 @@
|
||||
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||
<string name="reminder_action_snooze">Snooze</string>
|
||||
<string name="reminder_action_dismiss">Dismiss</string>
|
||||
<string name="reminder_ring_channel_name">Ringing reminders</string>
|
||||
<string name="reminder_ring_channel_description">Full-screen reminders that ring until you dismiss them. Calendula plays their sound itself.</string>
|
||||
<!-- Day context prefix in a reminder for an event on another day (v2.15.0) -->
|
||||
<string name="reminder_day_today">Today</string>
|
||||
<string name="reminder_day_tomorrow">Tomorrow</string>
|
||||
@@ -731,7 +733,26 @@
|
||||
<string name="reminder_custom_amount">Amount</string>
|
||||
<string name="reminder_custom_set">Set</string>
|
||||
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
|
||||
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
|
||||
<string name="settings_calendar_reminders_hint">Turn notifications off or pick a sound per calendar, and override the default reminder for calendars you can edit.</string>
|
||||
<string name="settings_calendar_notifications">Notifications</string>
|
||||
<string name="settings_calendar_notifications_hint">Notify at this calendar\'s reminder times</string>
|
||||
<string name="settings_calendar_notifications_off">Notifications off</string>
|
||||
<string name="settings_notification_sound">Notification sound</string>
|
||||
<string name="settings_notification_sound_hint">Sound, vibration and pop-ups for each calendar, in system settings</string>
|
||||
<string name="settings_calendar_notification_sound_hint">Sound, vibration and pop-ups for this calendar, in system settings</string>
|
||||
<string name="settings_alert_style">Alert style</string>
|
||||
<string name="settings_alert_style_notification">Notification</string>
|
||||
<string name="settings_alert_style_notification_summary">A notification that pops up and waits in the shade</string>
|
||||
<string name="settings_alert_style_full_screen">Full-screen alert</string>
|
||||
<string name="settings_alert_style_full_screen_summary">Fills the screen, even on the lock screen, until you dismiss or snooze it</string>
|
||||
<string name="settings_alert_sound">Full-screen sound</string>
|
||||
<string name="settings_alert_sound_once">Play sound once</string>
|
||||
<string name="settings_alert_sound_once_summary">This calendar\'s notification sound, once</string>
|
||||
<string name="settings_alert_sound_ring">Ring until dismissed</string>
|
||||
<string name="settings_alert_sound_ring_summary">Your alarm sound and vibration, like an alarm, also in Do Not Disturb when alarms may sound</string>
|
||||
<string name="settings_calendar_notification_sound_ring_hint">Ringing reminders use your alarm sound. This sets the sound of the ones handed back to the shade.</string>
|
||||
<string name="settings_full_screen_permission">Allow full-screen alerts</string>
|
||||
<string name="settings_full_screen_permission_hint">Android blocks Calendula\'s full-screen alerts, so they only pop up as notifications. Tap to allow them.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
||||
<string name="settings_reliable_delivery">Reliable delivery</string>
|
||||
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
@@ -601,6 +602,31 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(9L, 30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendar notifications default on and toggle per calendar`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
assertThat(prefs.mutedReminderCalendarIds.first()).isEmpty()
|
||||
prefs.setCalendarRemindersEnabled(7L, false)
|
||||
prefs.setCalendarRemindersEnabled(9L, false)
|
||||
assertThat(prefs.mutedReminderCalendarIds.first()).containsExactly(7L, 9L)
|
||||
prefs.setCalendarRemindersEnabled(7L, true)
|
||||
assertThat(prefs.mutedReminderCalendarIds.first()).containsExactly(9L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendar alert styles default to notifications and persist per calendar`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
assertThat(prefs.reminderAlertStyles.first()).isEmpty()
|
||||
prefs.setCalendarAlertStyle(7L, ReminderAlertStyle.FullScreenRing)
|
||||
prefs.setCalendarAlertStyle(9L, ReminderAlertStyle.FullScreen)
|
||||
prefs.setCalendarAlertStyle(9L, ReminderAlertStyle.Notification)
|
||||
assertThat(prefs.reminderAlertStyles.first()).containsExactly(7L, ReminderAlertStyle.FullScreenRing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage per-calendar duration entries are dropped, the rest survive`(
|
||||
@TempDir tempDir: Path,
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ReminderChannelPlanTest {
|
||||
|
||||
private fun calendar(id: Long, account: String, type: String, local: Boolean = false) = CalendarSource(
|
||||
id = id,
|
||||
displayName = "Cal $id",
|
||||
accountName = account,
|
||||
accountType = type,
|
||||
color = 0,
|
||||
isVisibleInSystem = true,
|
||||
isLocal = local,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `one channel per calendar, grouped by account`() {
|
||||
val plan = planReminderChannels(
|
||||
calendars = listOf(
|
||||
calendar(1, "me@example.org", "com.google"),
|
||||
calendar(2, "me@example.org", "com.google"),
|
||||
calendar(3, "dav", "bitfire.at.davdroid"),
|
||||
),
|
||||
existing = emptyList(),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
description = DESC,
|
||||
)
|
||||
assertThat(plan.groups).hasSize(2)
|
||||
assertThat(plan.channels.map { it.id })
|
||||
.containsExactly(reminderChannelId(1), reminderChannelId(2), reminderChannelId(3))
|
||||
assertThat(plan.channels[0].groupId).isEqualTo(plan.channels[1].groupId)
|
||||
assertThat(plan.channels[2].groupId).isNotEqualTo(plan.channels[0].groupId)
|
||||
assertThat(plan.staleChannelIds).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `device calendars share one group`() {
|
||||
val plan = planReminderChannels(
|
||||
calendars = listOf(
|
||||
calendar(1, "Calendula", "LOCAL", local = true),
|
||||
calendar(2, "Other", "LOCAL", local = true),
|
||||
),
|
||||
existing = emptyList(),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
description = DESC,
|
||||
)
|
||||
assertThat(plan.groups).hasSize(1)
|
||||
assertThat(plan.groups.single().isLocal).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same account name under two types is ambiguous`() {
|
||||
val plan = planReminderChannels(
|
||||
calendars = listOf(
|
||||
calendar(1, "me@example.org", "com.google"),
|
||||
calendar(2, "me@example.org", "bitfire.at.davdroid"),
|
||||
calendar(3, "work", "bitfire.at.davdroid"),
|
||||
),
|
||||
existing = emptyList(),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
description = DESC,
|
||||
)
|
||||
assertThat(plan.groups.filter { it.ambiguous }.map { it.accountType })
|
||||
.containsExactly("com.google", "bitfire.at.davdroid")
|
||||
assertThat(plan.groups.single { it.label == "work" }.ambiguous).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale reminder channels and groups go, foreign ones stay`() {
|
||||
val current = calendar(1, "me@example.org", "com.google")
|
||||
fun plan(calendars: List<CalendarSource>, existing: List<String> = emptyList(), groups: List<String> = emptyList()) =
|
||||
planReminderChannels(calendars, existing.map { ExistingReminderChannel(it, "Cal ${it.last()}", DESC, null) }, groups, "Unnamed", DESC)
|
||||
val keptGroup = plan(listOf(current)).groups.single().id
|
||||
val goneGroup = plan(listOf(calendar(9, "old", "com.google"))).groups.single().id
|
||||
val result = plan(
|
||||
calendars = listOf(current),
|
||||
existing = listOf(reminderChannelId(1), reminderChannelId(9), LEGACY_REMINDER_CHANNEL_ID, "backup"),
|
||||
groups = listOf(keptGroup, goneGroup, "other"),
|
||||
)
|
||||
assertThat(result.staleChannelIds).containsExactly(reminderChannelId(9))
|
||||
assertThat(result.staleGroupIds).containsExactly(goneGroup)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only missing or renamed channels are written`() {
|
||||
val plan = planReminderChannels(
|
||||
calendars = listOf(
|
||||
calendar(1, "a", "com.google"),
|
||||
calendar(2, "a", "com.google"),
|
||||
calendar(3, "a", "com.google").copy(displayName = ""),
|
||||
calendar(4, "a", "com.google"),
|
||||
),
|
||||
existing = listOf(
|
||||
ExistingReminderChannel(reminderChannelId(1), "Cal 1", DESC, "g"),
|
||||
ExistingReminderChannel(reminderChannelId(4), "Cal 4", "Old description", "g"),
|
||||
ExistingReminderChannel(reminderChannelId(2), "Old name", DESC, "g"),
|
||||
),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
description = DESC,
|
||||
)
|
||||
assertThat(plan.channelsToWrite.map { it.calendarId }).containsExactly(2L, 3L, 4L)
|
||||
assertThat(plan.channelsToWrite.single { it.calendarId == 3L }.name).isEqualTo("Unnamed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-syncing calendars keep a channel but get none up front`() {
|
||||
val quiet = calendar(2, "a", "com.google").copy(syncsEvents = false)
|
||||
val fresh = planReminderChannels(listOf(quiet), emptyList(), emptyList(), "Unnamed", DESC)
|
||||
assertThat(fresh.channelsToWrite).isEmpty()
|
||||
assertThat(fresh.groupsInUse).isEmpty()
|
||||
val posted = planReminderChannels(
|
||||
calendars = listOf(quiet),
|
||||
existing = listOf(ExistingReminderChannel(reminderChannelId(2), "Cal 2", DESC, null)),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
description = DESC,
|
||||
)
|
||||
assertThat(posted.staleChannelIds).isEmpty()
|
||||
assertThat(posted.channelsToWrite.map { it.calendarId }).containsExactly(2L)
|
||||
assertThat(posted.groupsInUse).hasSize(1)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DESC = "Reminders"
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ReminderAlertStyleTest {
|
||||
|
||||
@Test
|
||||
fun `styles round-trip through their stored form`() {
|
||||
val styles = mapOf(9L to ReminderAlertStyle.FullScreenRing, 3L to ReminderAlertStyle.FullScreen)
|
||||
|
||||
assertThat(parseAlertStyles(styles.toStoredAlertStyles())).isEqualTo(styles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plain notifications are not stored`() {
|
||||
val stored = mapOf(1L to ReminderAlertStyle.Notification, 2L to ReminderAlertStyle.FullScreen).toStoredAlertStyles()
|
||||
|
||||
assertThat(stored).isEqualTo("2=full_screen")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbled entries are dropped, the rest survive`() {
|
||||
assertThat(parseAlertStyles("x=full_screen;4=loud;5;6=full_screen_ring"))
|
||||
.containsExactly(6L, ReminderAlertStyle.FullScreenRing)
|
||||
assertThat(parseAlertStyles(null)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a calendar without a style uses notifications`() {
|
||||
val styles = mapOf(1L to ReminderAlertStyle.FullScreen)
|
||||
|
||||
assertThat(styles.alertStyleFor(1L)).isEqualTo(ReminderAlertStyle.FullScreen)
|
||||
assertThat(styles.alertStyleFor(2L)).isEqualTo(ReminderAlertStyle.Notification)
|
||||
assertThat(ReminderAlertStyle.Notification.isFullScreen).isFalse()
|
||||
assertThat(ReminderAlertStyle.FullScreenRing.isFullScreen).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -391,6 +391,49 @@ gate also covers a snooze re-shown from our own alarm after its calendar was
|
||||
switched off. The drawer's filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a
|
||||
separate in-app declutter that never touches reminders.
|
||||
|
||||
**A channel per calendar (#145).** `ReminderChannels` keeps one notification
|
||||
channel per calendar, grouped by account (one that syncs no events only gets
|
||||
its channel once something posts on it), so a calendar can be
|
||||
muted or given its own sound in the system settings before it ever fires. Every
|
||||
scan with reminders on syncs them (skipped when nothing changed): missing
|
||||
channels are created, renamed ones updated, and channels of deleted calendars
|
||||
removed — but never on an empty snapshot, which is a provider hiccup more often
|
||||
than every calendar gone. Switched-off calendars keep theirs, so toggling one
|
||||
never cancels a showing reminder or loses its settings. `post` creates a missing
|
||||
channel itself, so a reminder never waits on a sync. A new channel copies the sound, vibration and
|
||||
importance of the pre-#145 single `reminders` channel, which is deleted once
|
||||
nothing showing still sits on it. Settings → Notifications only deep-links into
|
||||
the system's channel screens (#340); the app never sets a sound itself. Separate
|
||||
from both, `SettingsPrefs.mutedReminderCalendarIds` is the in-app per-calendar
|
||||
off switch (#272): the scan plans nothing for those calendars (so they arm no
|
||||
wake-ups; toggling it re-scans), and `ReminderNotifier.post` checks it again
|
||||
for snoozes.
|
||||
|
||||
**Full-screen alerts (#171).** Settings → Notifications gives each calendar an
|
||||
alert style (`SettingsPrefs.reminderAlertStyles`): a plain notification, a
|
||||
full-screen alert that sounds once, or one that rings until handled. Only
|
||||
`ReminderNotifier.post` reads it, and `ReminderChannels.channelFor(id, style)`
|
||||
picks the channel. "Sound once" stays on the calendar's own channel, so its
|
||||
sound is still the one chosen in system settings, and adds a full-screen intent
|
||||
to `ReminderAlertActivity`. "Ring" starts `ReminderRingService`, which goes
|
||||
foreground on the shared silent `reminders_ring` channel and loops the alarm
|
||||
sound with vibration through floret-kit's `ring` module — the same player
|
||||
Clockula's alarms use. One reminder rings at a time; a newer one takes over and
|
||||
hands the older back to the shade quietly, as does a ring left unanswered for
|
||||
ten minutes, and switching the calendar away from ringing. Dismiss and Snooze,
|
||||
from the screen, the notification or a swipe, all go through
|
||||
`ReminderActionReceiver`, so the ring stops and the screen closes
|
||||
(`FullScreenAlerts`) however the reminder was handled. Starts and stops reach
|
||||
the service as commands, so a dismiss sent while a newer ring is on its way
|
||||
applies in order rather than killing it, and the service re-reads the settings
|
||||
once a ring arrives, so a style change or mute that raced a scan still wins. A
|
||||
blocked calendar, account group or ring channel never rings, the tone pauses
|
||||
for a phone call, and a ring restarted after a process death only rings out
|
||||
what was left of its ten minutes. When Android 14+
|
||||
withholds `USE_FULL_SCREEN_INTENT`, the intent is left off and the alert is a
|
||||
heads-up; Settings shows a row leading to the system switch. When Android
|
||||
refuses the ring service, the reminder falls back to sounding once.
|
||||
|
||||
Deliberately absent: a fallback to the provider's `EVENT_REMINDER` broadcast.
|
||||
Keeping both would double-post wherever the provider works, and Etar's way out —
|
||||
a latch that disables its own scheduling once a real broadcast arrives — cannot
|
||||
|
||||
+2
-3
@@ -96,9 +96,8 @@ The settings screen contains links to the source code, the licence, the issue tr
|
||||
- `USE_EXACT_ALARM`, `SCHEDULE_EXACT_ALARM`: deliver reminders at the exact time, including after snoozing.
|
||||
- `RECEIVE_BOOT_COMPLETED`: re-register pending reminders after a restart.
|
||||
- `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`: only to open the system dialog for the “Reliable delivery” setting.
|
||||
- `WAKE_LOCK`, `FOREGROUND_SERVICE`, `ACCESS_NETWORK_STATE`: required by the Android system component used for scheduled background work (WorkManager).
|
||||
|
||||
`ACCESS_NETWORK_STATE` allows reading *whether* a network connection exists. It does **not** permit using one. Without `INTERNET`, no connection is possible.
|
||||
- `USE_FULL_SCREEN_INTENT`, `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SYSTEM_EXEMPTED`, `VIBRATE`: for calendars you set to full-screen alerts — showing the alert over the lock screen and, with “Ring until dismissed”, ringing and vibrating until you dismiss or snooze it.
|
||||
- `WAKE_LOCK`: keeps the phone awake while a full-screen alert rings, and lets the Android component that runs scheduled background work (WorkManager) finish it.
|
||||
|
||||
## 7. Distribution channels
|
||||
|
||||
|
||||
+1
-1
Submodule floret-kit updated: a039ab5bd4...1c6c96fe33.
Reference in new issue
Block a user