Compare commits

..
Author SHA1 Message Date
Jean-Luc Makiolaandmakiolaj ad8472c65f Reminders follow completions made outside the notification (#127, #129) (#130)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Successful in 13m29s
In the app's own store, nothing re-ran the reminder scheduler when a task was completed in the list, the detail screen or a widget, or when a sync pulled in a completion from another device. The alarm stayed armed until the next app start or the daily maintenance pass. A reminder that was already showing never came down, because only its own "Done" button cleared it.

There was a second cause for repeating tasks. A ticked occurrence is saved as a row with its own id, but `occurrence()` searched the list by the series id. It never found the ticked occurrence, fell back to the next open one and posted the reminder anyway. That's probably the first half of #127.

- `ReminderRefresher` runs one debounced `ReminderScheduler.sync()` per burst of changes to tasks, lists or alarms. Account bookkeeping doesn't trigger it. The refresher and `TaskWidgetUpdater` now share a `StoreChangeDebouncer`.
- Each scheduler pass withdraws posted reminders whose occurrence is closed. It also runs when reminders are switched off. It only looks at reminders from the current storage mode.
  - A missing task counts as deleted only in Agendula's own store. A provider that fails a read returns nothing rather than throwing.
- Reminders now record their occurrence in the notification extras. Reminders posted by the previous version are only withdrawn for tasks that don't repeat.
- `occurrence()` looks up occurrences by their start time, so it finds ticked rows. It falls back to scanning the list for providers whose exception rows the direct lookup misses.
- A fired reminder now stays marked as fired for the 6 h grace period, even after its task is ticked. Ticking a task and then tapping Undo no longer makes the reminder ring again.
- After posting, the receivers check the task once more. That catches a completion that lands between the check and the post.
- These callers no longer run the scheduler themselves, because the refresher now covers them: the edit screen, the Done action, a storage-mode switch, and the two import paths in Settings. `ProviderWriter` asks the refresher for a pass instead of syncing directly.
  - `SyncWorker` still syncs directly. Once the worker returns, nothing keeps the process alive for the debounce.

Closes #127
Closes #129

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/130
2026-11-02 18:54:13 +01:00
makiolaj c2320de60b Prepare 1.3.0-beta.2
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Failing after 10m54s
2026-10-28 17:57:59 +01:00
makiolaj 62ee105f9d App name picker shows launcher previews like Calendula (#104) 2026-10-28 17:57:59 +01:00
23 changed files with 356 additions and 82 deletions

No files matched your search

+6 -1
View File
@@ -17,7 +17,8 @@ All notable changes to this project are documented here. The format follows
- Pull down on the task list to sync (#73).
- Task list sections can be collapsed (#91).
- A pure black theme for AMOLED screens (#99).
- Agendula can show up as "Tasks" in your launcher (#104).
- Agendula can show up as "Tasks" in your launcher, picked from launcher
previews as in Calendula (#104).
- Reminder notifications show the task's description (#94).
### Changed
@@ -25,6 +26,10 @@ All notable changes to this project are documented here. The format follows
afterwards (#93).
- A swipe that starts at the screen edge no longer swipes the task (#90).
### Fixed
- Completing a task from the list, a widget or another device now cancels its
upcoming reminder and clears one already showing (#127, #129).
## [1.2.0] - 2026-10-26
### Added
+2 -2
View File
@@ -33,8 +33,8 @@ android {
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 1030001
versionName = "1.3.0-beta.1"
versionCode = 1030002
versionName = "1.3.0-beta.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -56,8 +56,8 @@ class SyncWorker @AssistedInject constructor(
// log line is the same as saying nothing. Only what is new: the
// store has already dropped whatever the user has been told.
noticeNotifier.post(accountName, outcome.notices)
// Nothing else re-arms reminders for what a sync pulled in: in our
// own store no provider broadcast fires.
// Here rather than left to ReminderRefresher's debounce: once
// the worker returns, nothing keeps the process alive for it.
if (outcome.reports.any { it.hadWork || it.discardedEdits.isNotEmpty() }) {
runCatching { reminderScheduler.sync() }
}
@@ -12,6 +12,7 @@ import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.agendula.data.reminders.ReminderRefresher
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
@@ -65,15 +66,12 @@ class AgendulaApp : Application(), Configuration.Provider {
// v0.3.x install's tasks, both before anything reads a store.
val startupGate = entryPoint.startupGate()
val scope = entryPoint.applicationScope()
// An alarm is armed off whichever store was active when it was scheduled,
// so a switch has to rebuild the set. Armed only once startup's own
// null -> stored transition is past, which the launch sync below covers.
// A switch rebuilds the alarms through ReminderRefresher, which hears it
// as a store change. Channels only once startup's own null -> stored
// transition is past, which the launch sync below covers.
val started = AtomicBoolean(false)
entryPoint.providerResolver().onModeChanged {
if (started.get()) {
scope.launch { runCatching { scheduler.sync() } }
runCatching { entryPoint.taskNotifier().refreshChannel() }
}
if (started.get()) runCatching { entryPoint.taskNotifier().refreshChannel() }
}
startupGate.start()
refreshNotificationChannels()
@@ -88,6 +86,7 @@ class AgendulaApp : Application(), Configuration.Provider {
scheduler.sync()
}
runCatching { entryPoint.taskWidgetUpdater().start() }
runCatching { entryPoint.reminderRefresher().start() }
runCatching { entryPoint.providerSnapshotSource().start() }
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
}
@@ -124,6 +123,7 @@ class AgendulaApp : Application(), Configuration.Provider {
fun taskNotifier(): TaskNotifier
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
fun taskWidgetUpdater(): TaskWidgetUpdater
fun reminderRefresher(): ReminderRefresher
fun providerSnapshotSource(): ProviderSnapshotSource
}
}
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
@@ -15,6 +16,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Instant
/**
* Fires when a task's reminder alarm goes off. Re-reads the task (it may have
@@ -44,8 +46,7 @@ class DueReminderReceiver : BroadcastReceiver() {
}
val settings = settingsPrefs.settings.first()
if (!settings.remindersEnabled) return@launch
val task = runCatching { dataSource.occurrence(taskId, occurrence) }.getOrNull()
if (task != null && !task.isClosed) notifier.postDue(task, settings)
runCatching { notifier.postIfOpen(dataSource, taskId, occurrence, settings) }
} finally {
pending.finish()
}
@@ -74,17 +75,51 @@ class DueReminderReceiver : BroadcastReceiver() {
/**
* The occurrence of [taskId] anchored at [occurrenceStart] (epoch millis), or the
* task's current one when there is no anchor or it can no longer be found.
* [TasksDataSource.task] alone resolves a series to whichever occurrence is
* current, which is not necessarily the one a reminder was armed for.
*
* ⚠️ A ticked or edited occurrence is a row of its own with its own id, so
* matching list rows on [taskId] misses it; [TasksDataSource.task] with the
* anchor finds it by start. The list scan is for a provider whose exception
* rows that lookup does not reach.
*/
internal fun TasksDataSource.occurrence(taskId: Long, occurrenceStart: Long): Task? {
val current = task(taskId) ?: return null
if (occurrenceStart == ScheduledReminder.NO_OCCURRENCE ||
current.occurrenceStart?.toEpochMilliseconds() == occurrenceStart
) {
return current
val anchor = occurrenceStart.takeIf { it != ScheduledReminder.NO_OCCURRENCE }?.let(Instant::fromEpochMilliseconds)
val found = task(taskId, anchor) ?: return null
if (anchor == null || found.occurrenceStart == anchor) return found
return tasks(TaskQuery(listId = found.listId, includeCompleted = true))
.firstOrNull { (it.taskId == taskId || it.seriesId == taskId) && it.occurrenceStart == anchor }
?: found
}
/**
* Whether a reminder posted for [occurrenceStart] is done with, given [current]
* as [occurrence] resolved it.
*
* - No task: gone, but only when [missingMeansGone] — a provider that fails a
* read hands back nothing rather than throwing.
* - [occurrenceStart] null: posted before reminders recorded their occurrence,
* so only a task that does not recur can be judged.
* - A lookup that fell back to another occurrence says nothing about the
* reminded one, so it stays.
*/
internal fun reminderIsStale(occurrenceStart: Long?, current: Task?, missingMeansGone: Boolean): Boolean {
if (current == null) return missingMeansGone
val same = when (occurrenceStart) {
null -> current.occurrenceStart == null
ScheduledReminder.NO_OCCURRENCE -> true
else -> current.occurrenceStart?.toEpochMilliseconds() == occurrenceStart
}
return tasks(TaskQuery(listId = current.listId, includeCompleted = true))
.firstOrNull { it.taskId == taskId && it.occurrenceStart?.toEpochMilliseconds() == occurrenceStart }
?: current
return same && current.isClosed
}
/**
* Posts the reminder for [taskId]'s occurrence if it is still open, then checks
* once more: a completion landing between the read and the post may already
* have had its withdrawal pass, which ran before there was anything to withdraw.
*/
internal fun TaskNotifier.postIfOpen(dataSource: TasksDataSource, taskId: Long, occurrenceStart: Long, settings: Settings) {
val task = dataSource.occurrence(taskId, occurrenceStart) ?: return
if (task.isClosed) return
postDue(task, settings)
val now = runCatching { dataSource.occurrence(taskId, occurrenceStart) }.getOrElse { return }
if (now == null || now.isClosed) cancel(taskId)
}
@@ -32,7 +32,6 @@ class ReminderActionReceiver : BroadcastReceiver() {
@Inject lateinit var notifier: TaskNotifier
@Inject lateinit var snoozeScheduler: ReminderSnoozeScheduler
@Inject lateinit var reminderScheduler: ReminderScheduler
@Inject lateinit var repository: TasksRepository
@Inject lateinit var dataSource: TasksDataSource
@Inject lateinit var settingsPrefs: SettingsPrefs
@@ -54,7 +53,6 @@ class ReminderActionReceiver : BroadcastReceiver() {
val anchor = occurrence.takeIf { it != ScheduledReminder.NO_OCCURRENCE }
?.let(Instant::fromEpochMilliseconds)
repository.setCompleted(taskId, anchor, completed = true)
reminderScheduler.sync()
}
ACTION_SNOOZE -> runCatching {
@@ -67,8 +65,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
val settings = settingsPrefs.settings.first()
if (!settings.remindersEnabled) return@runCatching
startupGate.awaitReady()
val task = dataSource.occurrence(taskId, occurrence)
if (task != null && !task.isClosed) notifier.postDue(task, settings)
notifier.postIfOpen(dataSource, taskId, occurrence, settings)
}
}
} finally {
@@ -21,7 +21,9 @@ internal data class ReminderDiff(
fired: Set<ScheduledReminder> = emptySet(),
isArmed: (ScheduledReminder) -> Boolean,
): ReminderDiff {
val live = previous.filterTo(HashSet()) { it in fired || isArmed(it) }
// A fired entry counts even once the store dropped it, so a task
// reopened within the grace does not ring again.
val live = previous.filterTo(HashSet()) { it in fired || isArmed(it) } + fired
return ReminderDiff(
cancel = live - desired,
arm = desired - live,
@@ -0,0 +1,35 @@
package de.jeanlucmakiola.agendula.data.reminders
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.StoreChangeDebouncer
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineScope
import javax.inject.Inject
import javax.inject.Singleton
/**
* Keeps reminders in step with the store: any change to tasks, lists or alarms —
* a tick in the list or on a widget, a sync pulling in a completion from another
* device, an edit, a store switch — runs one debounced [ReminderScheduler.sync].
* Started once from `AgendulaApp`, after the startup gate, so it observes the
* right store.
*/
@Singleton
class ReminderRefresher @Inject constructor(
dataSource: TasksDataSource,
scheduler: ReminderScheduler,
@ApplicationScope scope: CoroutineScope,
) {
private val changes = StoreChangeDebouncer(scope, DEBOUNCE_MILLIS, dataSource::registerTaskObserver) { scheduler.sync() }
fun start() {
changes.start()
}
/** Re-sync soon, folded into any pass a store change already asked for. */
fun requestRefresh() = changes.request()
private companion object {
const val DEBOUNCE_MILLIS = 1_000L
}
}
@@ -8,6 +8,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineDispatcher
@@ -23,8 +24,9 @@ import javax.inject.Singleton
* The self-scheduled due-reminder engine. Nothing else delivers task reminders —
* not the platform, not a tasks provider — so Agendula reads upcoming due tasks
* and arms one exact [AlarmManager] alarm per reminder, within a rolling window. Re-run
* on app start, on boot, on a store switch, and on an external provider change;
* it diffs against [ScheduledReminderStore] so only changed alarms move.
* on app start, on boot, on a store switch and on every change to the store (see
* [ReminderRefresher]); it diffs against [ScheduledReminderStore] so only changed
* alarms move, and takes down posted reminders whose task is done with.
*/
@Singleton
class ReminderScheduler @Inject constructor(
@@ -34,6 +36,7 @@ class ReminderScheduler @Inject constructor(
private val store: ScheduledReminderStore,
private val providerResolver: ProviderResolver,
private val snoozeScheduler: ReminderSnoozeScheduler,
private val notifier: TaskNotifier,
@IoDispatcher private val io: CoroutineDispatcher,
) {
private val syncLock = Mutex()
@@ -65,19 +68,23 @@ class ReminderScheduler @Inject constructor(
// Gate on whether the store is readable, not on whether a provider
// resolves: our own store deliberately resolves to no provider, so the
// latter clears every reminder in the default mode.
if (!settings.remindersEnabled || !providerResolver.canReadStore()) {
val readable = providerResolver.canReadStore()
if (!settings.remindersEnabled || !readable) {
clearAll()
// Switched off, a reminder already up still goes when its task does.
if (readable) withdrawStale()
return
}
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
.getOrElse { return }
// Per-task leads. One query for all of them.
val perTask = runCatching { dataSource.reminders() }.getOrElse { emptyMap() }
val now = System.currentTimeMillis()
val desired = ReminderPlanner.plan(
tasks = tasks,
perTask = perTask,
settings = settings,
now = System.currentTimeMillis(),
now = now,
zone = ZoneId.systemDefault(),
)
@@ -92,7 +99,10 @@ class ReminderScheduler @Inject constructor(
// ⚠️ The store survives reboot, force-stop and restore; the alarms don't.
// Trusting it alone left every reminder after a reboot unarmed for good.
val fired = store.fired()
// ⚠️ Kept through the missed-reminder grace even once it drops out of
// the plan: a tick undone a moment later would otherwise re-arm a
// trigger in the past, and the reminder would ring a second time.
val fired = store.fired().filterTo(HashSet()) { it in desired || it.triggerAt >= now - ReminderPlanner.MISSED_GRACE_MS }
val diff = ReminderDiff.of(store.all(), desired, fired) { !afterReboot && !rearm && isArmed(it) }
diff.cancel.forEach { cancel(it) }
// ⚠️ Only what was actually armed. A throw mid-loop used to skip the
@@ -100,7 +110,25 @@ class ReminderScheduler @Inject constructor(
// — uncancellable, and firing for tasks that no longer exist — and the
// exception escaped into BootReceiver's goAsync().
val armed = diff.arm.filter { schedule(it) }
store.replace(diff.keep + armed, fired = fired intersect desired, exact = canExact)
store.replace(diff.keep + armed, fired = fired, exact = canExact)
withdrawStale()
}
/**
* A reminder already on screen outlives its alarm: completing the task in
* the list, on a widget or on another device has to take it down too.
*/
private fun withdrawStale() {
// Only our own store tells a deleted task from a failed read.
val missingMeansGone = providerResolver.mode() == StorageMode.OWN
runCatching {
notifier.withdrawStale { taskId, occurrence ->
runCatching {
val current = dataSource.occurrence(taskId, occurrence ?: ScheduledReminder.NO_OCCURRENCE)
reminderIsStale(occurrence, current, missingMeansGone)
}.getOrDefault(false)
}
}
}
/** Whether the system still holds this alarm's PendingIntent. */
@@ -10,6 +10,7 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.text.format.DateFormat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@@ -103,6 +104,11 @@ class TaskNotifier @Inject constructor(
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(tapIntent)
.addExtras(
Bundle().apply {
putLong(EXTRA_OCCURRENCE, task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE)
},
)
.addAction(0, context.getString(R.string.reminder_action_done), actionIntent(task, ReminderActionReceiver.ACTION_DONE))
.addAction(
0,
@@ -117,6 +123,24 @@ class TaskNotifier @Inject constructor(
NotificationManagerCompat.from(context).cancel(taskId.toString(), NOTIFICATION_ID)
}
/**
* Takes down every posted reminder [isStale] says is done with — completed,
* cancelled or deleted since it went off, here or on another device. The
* occurrence is null for a reminder posted before it was recorded.
*/
fun withdrawStale(isStale: (taskId: Long, occurrenceStart: Long?) -> Boolean) {
// Only the current store's: another store's task ids name other tasks.
val prefix = "$LIST_CHANNEL_PREFIX${resolver.mode().name.lowercase()}_"
manager.activeNotifications
.filter { it.id == NOTIFICATION_ID && it.notification.channelId?.startsWith(prefix) == true }
.forEach { posted ->
val taskId = posted.tag?.toLongOrNull() ?: return@forEach
val extras = posted.notification.extras
val occurrence = if (extras.containsKey(EXTRA_OCCURRENCE)) extras.getLong(EXTRA_OCCURRENCE) else null
if (isStale(taskId, occurrence)) cancel(taskId)
}
}
private fun actionIntent(task: Task, action: String): PendingIntent = PendingIntent.getBroadcast(
context,
task.taskId.toInt(),
@@ -190,6 +214,7 @@ class TaskNotifier @Inject constructor(
private const val LEGACY_CHANNEL_ID = "task_reminders"
private const val LIST_CHANNEL_PREFIX = "reminders_"
private const val NOTIFICATION_ID = 1
private const val EXTRA_OCCURRENCE = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
private const val CHANNEL_PREFS = "reminder_channels"
private const val KEY_LEGACY_IMPORTANCE = "legacy_importance"
@@ -81,16 +81,22 @@ class ModeRoutingTasksDataSource(
* So the registration moves with the mode, and the switch itself counts as a
* change: the data underneath every live flow has just been replaced.
*/
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
override fun registerObserver(onChange: () -> Unit): AutoCloseable =
follow(onChange) { it.registerObserver(onChange) }
override fun registerTaskObserver(onChange: () -> Unit): AutoCloseable =
follow(onChange) { it.registerTaskObserver(onChange) }
private fun follow(onChange: () -> Unit, register: (TasksDataSource) -> AutoCloseable): AutoCloseable {
val lock = Any()
var closed = false
var handle: AutoCloseable? = runCatching { active().registerObserver(onChange) }.getOrNull()
var handle: AutoCloseable? = runCatching { register(active()) }.getOrNull()
val modeHandle = resolver.onModeChanged {
synchronized(lock) {
if (!closed) {
handle?.let { runCatching { it.close() } }
handle = runCatching { active().registerObserver(onChange) }.getOrNull()
handle = runCatching { register(active()) }.getOrNull()
}
}
onChange()
@@ -0,0 +1,39 @@
package de.jeanlucmakiola.agendula.data.tasks
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
/**
* Runs [action] once per burst of store changes: [register] hooks the store's
* observer, and [request] asks for a pass without one. Started at most once.
*/
class StoreChangeDebouncer(
private val scope: CoroutineScope,
private val delayMillis: Long,
private val register: (onChange: () -> Unit) -> AutoCloseable,
private val action: suspend () -> Unit,
) {
private val requests = Channel<Unit>(Channel.CONFLATED)
private val started = AtomicBoolean(false)
/** @return whether this call started it; false when it already was. */
@OptIn(FlowPreview::class)
fun start(): Boolean {
if (!started.compareAndSet(false, true)) return false
runCatching { register { requests.trySend(Unit) } }
scope.launch(Dispatchers.IO) {
requests.receiveAsFlow().debounce(delayMillis).collect { runCatching { action() } }
}
return true
}
fun request() {
requests.trySend(Unit)
}
}
@@ -152,4 +152,7 @@ interface TasksDataSource {
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
fun registerObserver(onChange: () -> Unit): AutoCloseable
/** Like [registerObserver], but only for changes to tasks, lists and alarms — not account bookkeeping. */
fun registerTaskObserver(onChange: () -> Unit): AutoCloseable = registerObserver(onChange)
}
@@ -1,8 +1,7 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.database.sqlite.SQLiteConstraintException
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.reminders.ReminderRefresher
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
@@ -19,8 +18,6 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.normalizeTags
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.time.ZoneId
import java.util.UUID
import javax.inject.Inject
@@ -58,9 +55,8 @@ class ProviderWriter internal constructor(
store: RoomTasksDataSource,
source: ProviderSnapshotSource,
resolver: ProviderResolver,
scheduler: ReminderScheduler,
@ApplicationScope scope: CoroutineScope,
) : this(database, store, source, resolver, { scope.launch { runCatching { scheduler.sync() } } })
reminders: ReminderRefresher,
) : this(database, store, source, resolver, reminders::requestRefresh)
private var batchDepth = 0
private var batchWrote = false
@@ -76,6 +76,7 @@ internal fun seriesOccurrences(master: TaskEntity, overrides: List<TaskEntity>,
}
internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
private val TASK_TABLES = arrayOf("tasks", "task_lists", "task_alarms")
/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */
private const val ACK_CACHE_SIZE = 512
@@ -650,8 +651,12 @@ class RoomTasksDataSource @Inject constructor(
// --- observation ----------------------------------------------------------
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
val observer = object : InvalidationTracker.Observer(OBSERVED_TABLES) {
override fun registerObserver(onChange: () -> Unit): AutoCloseable = observe(OBSERVED_TABLES, onChange)
override fun registerTaskObserver(onChange: () -> Unit): AutoCloseable = observe(TASK_TABLES, onChange)
private fun observe(tables: Array<String>, onChange: () -> Unit): AutoCloseable {
val observer = object : InvalidationTracker.Observer(tables) {
override fun onInvalidated(tables: Set<String>) = onChange()
}
database.invalidationTracker.addObserver(observer)
@@ -6,7 +6,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.Priority
@@ -109,7 +108,6 @@ data class TaskEditUiState(
class TaskEditViewModel @Inject constructor(
private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs,
private val reminderScheduler: ReminderScheduler,
) : ViewModel() {
private val _state = MutableStateFlow(TaskEditUiState())
@@ -446,13 +444,6 @@ class TaskEditViewModel @Inject constructor(
scope = scope,
)
}
// ⚠️ Outside the write's own result. The task is saved by this
// point, and reporting a scheduling failure as a *save* failure
// sends the user back to tap Save again — on a screen whose
// editingTaskId is still null, which creates a second task. The
// reminder is re-synced on the next data change and on launch;
// the duplicate is forever.
runCatching { reminderScheduler.sync() }
}.onSuccess {
_state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false)
}.onFailure { error ->
@@ -12,6 +12,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -23,8 +25,10 @@ import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.LauncherMark
import de.jeanlucmakiola.floret.components.LauncherNameChooser
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.currentLocale
@@ -106,15 +110,25 @@ internal fun AppearanceScreen(
}
if (showAppName) {
OptionPicker(
FullScreenPicker(
title = stringResource(R.string.settings_app_name),
options = LauncherName.entries,
selected = launcherName,
label = { launcherNameLabel(it) },
header = { GroupedHint(stringResource(R.string.settings_app_name_hint)) },
onSelect = viewModel::setLauncherName,
onDismiss = { showAppName = false },
)
predictiveBack = true,
) {
LauncherNameChooser(
summary = stringResource(R.string.settings_app_name_hint),
options = LauncherName.entries,
selected = launcherName,
label = { launcherNameLabel(it) },
onSelect = viewModel::setLauncherName,
mark = {
LauncherMark(
background = colorResource(R.color.ic_launcher_background),
foreground = painterResource(R.drawable.ic_launcher_foreground),
)
},
)
}
}
if (showTimeFormat) {
@@ -188,7 +188,6 @@ class SettingsViewModel @Inject constructor(
transferState.update { it.copy(confirming = false, running = true, outcome = null) }
viewModelScope.launch {
val result = externalImport.run()
if (result is TransferResult.Copied) runCatching { reminderScheduler.sync() }
transferState.update {
it.copy(
running = false,
@@ -212,7 +211,6 @@ class SettingsViewModel @Inject constructor(
retryingLegacyImport.value = true
viewModelScope.launch {
runCatching { oneShotImport.reimportFromArchive() }
runCatching { reminderScheduler.sync() }
retryingLegacyImport.value = false
}
}
@@ -3,14 +3,11 @@ package de.jeanlucmakiola.agendula.widget
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.StoreChangeDebouncer
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.floret.glance.WidgetRollover
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@@ -26,16 +23,10 @@ class TaskWidgetUpdater @Inject constructor(
private val dataSource: TasksDataSource,
@ApplicationScope private val scope: CoroutineScope,
) {
private val requests = Channel<Unit>(Channel.CONFLATED)
private var observer: AutoCloseable? = null
private val changes = StoreChangeDebouncer(scope, DEBOUNCE_MILLIS, dataSource::registerObserver, ::redraw)
@OptIn(FlowPreview::class)
fun start() {
if (observer != null) return
observer = runCatching { dataSource.registerObserver { requests.trySend(Unit) } }.getOrNull()
scope.launch(Dispatchers.IO) {
requests.receiveAsFlow().debounce(DEBOUNCE_MILLIS).collect { redraw() }
}
if (!changes.start()) return
scope.launch(Dispatchers.IO) {
runCatching { WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) }
}
@@ -44,9 +35,7 @@ class TaskWidgetUpdater @Inject constructor(
}
/** Redraw soon; bursts collapse into one pass. */
fun requestRefresh() {
requests.trySend(Unit)
}
fun requestRefresh() = changes.request()
private suspend fun redraw() {
runCatching {
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.agendula.data.reminders
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.testTask
import org.junit.jupiter.api.Test
import java.lang.reflect.Proxy
import kotlin.time.Instant
class OccurrenceLookupTest {
private val yesterday = Instant.fromEpochMilliseconds(1_800_000_000_000)
private val today = Instant.fromEpochMilliseconds(1_800_000_000_000 + 86_400_000)
private val current = testTask(id = 10).copy(seriesId = 10, occurrenceStart = today)
/** Answers only the two lookups [occurrence] makes. */
private fun source(byAnchor: (Instant?) -> Task?, list: List<Task> = emptyList()): TasksDataSource =
Proxy.newProxyInstance(TasksDataSource::class.java.classLoader, arrayOf(TasksDataSource::class.java)) { _, method, args ->
when {
method.name == "task" && args?.size == 2 -> byAnchor(args[1] as Instant?)
method.name == "tasks" -> list
else -> throw UnsupportedOperationException(method.name)
}
} as TasksDataSource
@Test
fun `a ticked occurrence is found although it is a row of its own`() {
val ticked = testTask(id = 57, status = TaskStatus.COMPLETED, completedAt = today)
.copy(seriesId = 10, occurrenceStart = yesterday)
val found = source({ if (it == yesterday) ticked else current }).occurrence(10, yesterday.toEpochMilliseconds())
assertThat(found).isEqualTo(ticked)
assertThat(reminderIsStale(yesterday.toEpochMilliseconds(), found, missingMeansGone = true)).isTrue()
}
@Test
fun `an exception row the anchored lookup misses is found in the list`() {
val exception = testTask(id = 99, status = TaskStatus.COMPLETED, completedAt = today)
.copy(seriesId = 10, occurrenceStart = yesterday)
val found = source({ current }, listOf(current, exception)).occurrence(10, yesterday.toEpochMilliseconds())
assertThat(found).isEqualTo(exception)
}
}
@@ -44,6 +44,14 @@ class ReminderDiffTest {
assertThat(diff.keep).containsExactly(a)
}
@Test
fun `a reopened task within the grace does not ring again`() {
// Ticked, so the store dropped it; undone, so it is wanted again.
val diff = ReminderDiff.of(previous = emptySet(), desired = setOf(a), fired = setOf(a)) { false }
assertThat(diff.arm).isEmpty()
assertThat(diff.keep).containsExactly(a)
}
@Test
fun `store entries round-trip, and junk is skipped`() {
val encoded = ScheduledReminderStore.encode(setOf(a, b))
@@ -0,0 +1,54 @@
package de.jeanlucmakiola.agendula.data.reminders
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.testTask
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class ReminderIsStaleTest {
private val none = ScheduledReminder.NO_OCCURRENCE
private val anchor = Instant.fromEpochMilliseconds(1_800_000_000_000)
@Test
fun `a deleted task's reminder comes down`() {
assertThat(reminderIsStale(none, null, missingMeansGone = true)).isTrue()
}
@Test
fun `a completed task's reminder comes down`() {
val done = testTask(status = TaskStatus.COMPLETED, completedAt = anchor)
assertThat(reminderIsStale(none, done, missingMeansGone = true)).isTrue()
}
@Test
fun `an open task's reminder stays`() {
assertThat(reminderIsStale(none, testTask(), missingMeansGone = true)).isFalse()
}
@Test
fun `a completed occurrence's reminder comes down`() {
val done = testTask(status = TaskStatus.COMPLETED, completedAt = anchor).copy(occurrenceStart = anchor)
assertThat(reminderIsStale(anchor.toEpochMilliseconds(), done, missingMeansGone = true)).isTrue()
}
@Test
fun `a lookup that fell back to another occurrence leaves the reminder up`() {
val other = testTask(status = TaskStatus.COMPLETED, completedAt = anchor)
.copy(occurrenceStart = Instant.fromEpochMilliseconds(anchor.toEpochMilliseconds() + 86_400_000))
assertThat(reminderIsStale(anchor.toEpochMilliseconds(), other, missingMeansGone = true)).isFalse()
}
@Test
fun `a failed provider read leaves the reminder up`() {
assertThat(reminderIsStale(none, null, missingMeansGone = false)).isFalse()
}
@Test
fun `a reminder from before occurrences were recorded comes down only for a task that does not recur`() {
val done = testTask(status = TaskStatus.COMPLETED, completedAt = anchor)
assertThat(reminderIsStale(null, done, missingMeansGone = true)).isTrue()
assertThat(reminderIsStale(null, done.copy(occurrenceStart = anchor), missingMeansGone = true)).isFalse()
}
}