Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad8472c65f | ||
|
|
c2320de60b | ||
|
|
62ee105f9d |
No files matched your search
+6
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+47
-12
@@ -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)
|
||||
}
|
||||
+1
-4
@@ -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"
|
||||
|
||||
|
||||
+9
-3
@@ -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()
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule floret-kit updated: e8d33329c8...a26bea7871.
Reference in new issue
Block a user