android: reminders that actually reach you (M12 step 6)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Android / Kotlin + Rust (debug APK) (push) Failing after 5m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m37s
Desktop (Tauri) / Update manifest (push) Successful in 5s

Reminders have been settable since the editor landed and have never once gone
off. The board showed them overdue in red, which tells you what you already know
by the time you are looking at the board.

**AlarmManager, not WorkManager.** The background sync is right to be on
WorkManager — nobody minds whether it runs at 3:05 or 3:19. A reminder minds
very much. WorkManager's periodic floor is fifteen minutes and it batches into
maintenance windows, so "remind me at 09:00" would routinely arrive at 09:14,
which is not a reminder, it is a rebuke.

**One alarm, not one per reminder.** Only the earliest future reminder is ever
scheduled; when it fires, everything due is announced and the next is scheduled.
A hundred reminders cost one alarm, and there is no incremental bookkeeping to
drift — `Reminders.refresh` recomputes the whole picture from the store, and is
called from everywhere anything could have changed: an edit, a foreground, a
background sync, boot, and an app update.

Boot and MY_PACKAGE_REPLACED both matter and both are easy to forget. Pending
alarms survive neither, and this app updates by APK from its own server, so
without that receiver a phone would silently stop reminding anyone of anything
after a restart — the worst kind of failure, because nothing appears wrong.

**Neither permission is treated as a prerequisite.**

SCHEDULE_EXACT_ALARM, not USE_EXACT_ALARM: the latter is granted at install with
no prompt and is reserved for apps whose whole purpose is an alarm clock or a
calendar, which this is not. Refusing the former costs precision, not the
feature — it falls back to an inexact alarm, because a reminder a few minutes
late beats no reminder.

POST_NOTIFICATIONS is asked for on the first launch where a reminder actually
exists, never at launch on an empty board. Android gives an app essentially one
chance at that dialog, and spending it before the person has any idea what this
app would send them is spending it on nothing. For anyone who refuses, or who
turns notifications off later in system settings, the Reminders view carries a
standing notice with a button to the right screen — a feature that silently does
nothing is worse than one that is plainly absent.

**A first run adopts overdue reminders silently.** The storm case is linking a
server and pulling months of history; a hundred notifications the moment someone
signs in is a good way to have the feature turned off before it is ever useful.
After that, a missed reminder is announced up to a day late — the web uses
fifteen minutes because an open tab has been polling every forty-five seconds,
but a phone can be switched off all night.

Done and Snooze act from the shade without opening the app. The dedupe key is
note id plus remind_at, the same one the web store uses, so snoozing produces a
new occurrence rather than one already dealt with.

Tapping a notification opens that note. The extra is CONSUMED when read: the
Activity keeps the intent it was launched with, so without that, rotating the
phone would replay it and reopen a note the person had already closed.

`Reminders` split into scheduling policy and `ReminderNotification` rendering
after detekt counted fourteen functions in one object — it was right, they answer
different questions and change for different reasons. `ForegroundTransitions`
moves to the ui package; the reminder notice needs it to re-read a permission the
person may have just changed in a system screen this app cannot observe.

Known gap, pre-existing and shared with every surface: `complete_reminder` in the
core clears a reminder without advancing recurrence — its own comment says so.
So tapping Done on a daily reminder ends it rather than moving it to tomorrow.
Not changed here because it is core behaviour the desktop and web also have, but
notifications make it much easier to hit, and it should be next.
This commit is contained in:
2026-08-19 20:06:05 -04:00
parent 39170b715c
commit 785ebdba59
14 changed files with 828 additions and 50 deletions
+41
View File
@@ -46,6 +46,28 @@
warning when the probed address is http://, BEFORE any credential field
appears. See SyncScreen.kt.
-->
<!--
Reminders.
POST_NOTIFICATIONS is a runtime permission from API 33. It is asked for in
context — the first time the app opens holding a reminder that could fire,
never at launch on an empty board, where there would be nothing to explain
why it is being asked.
SCHEDULE_EXACT_ALARM rather than USE_EXACT_ALARM. USE_EXACT_ALARM is granted
at install with no prompt, and is reserved for apps whose whole purpose is an
alarm clock or calendar; a note app claiming it would be claiming something
untrue. SCHEDULE_EXACT_ALARM is the one the person can grant or refuse, and
refusing costs precision, not the feature — see Reminders.scheduleNext.
RECEIVE_BOOT_COMPLETED already arrives via WorkManager (below), but is
declared here too because ReminderReceiver now depends on it directly. A
permission this file relies on should be visible in this file.
-->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".ThoughtSyncApplication"
android:allowBackup="true"
@@ -65,5 +87,24 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!--
Not exported: every intent that reaches it is one this app created, with
an explicit component. Exporting would let any app on the device mark
someone's reminders as done.
The two system broadcasts are the exception and need the filter, because
the system is the sender. Both exist for the same reason — pending alarms
do not survive either a reboot or an app update, so without this a phone
that restarts overnight would quietly stop reminding anyone of anything.
-->
<receiver
android:name=".ReminderReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -1,30 +1,33 @@
package com.fabledsword.thoughtsync
import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.thoughtsync.ui.BoardScreen
import com.fabledsword.thoughtsync.ui.BoardSync
import com.fabledsword.thoughtsync.ui.BoardViewModel
import com.fabledsword.thoughtsync.ui.ComposeSheet
import com.fabledsword.thoughtsync.ui.ForegroundTransitions
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
import com.fabledsword.thoughtsync.ui.SyncScreen
@@ -32,13 +35,27 @@ import com.fabledsword.thoughtsync.ui.SyncState
import com.fabledsword.thoughtsync.ui.SyncViewModel
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
import com.fabledsword.thoughtsync.ui.olderThan
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : ComponentActivity() {
/**
* The note a reminder notification asked for, waiting to be opened.
*
* Held on the Activity rather than passed to `setContent` once, because a tap
* on a notification while the app is already running arrives at [onNewIntent],
* not [onCreate] — the composition is long since built by then and the only
* way in is a piece of state it is already reading.
*/
private val requestedNote = mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val app = application as ThoughtSyncApplication
requestedNote.value = takeRequestedNote(intent)
setContent {
ThoughtSyncTheme {
@@ -49,11 +66,31 @@ class MainActivity : ComponentActivity() {
// than render an empty board that looks like data loss.
StoreUnavailableScreen(reason = app.openFailure)
} else {
App(core)
App(core, requestedNote)
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
requestedNote.value = takeRequestedNote(intent)
}
/**
* Read the note a notification asked for, and CONSUME it.
*
* The removal is the point. The Activity keeps the intent it was launched
* with, so without this, rotating the phone would replay it — reopening a note
* the person had tapped through and then closed, over and over, with no way to
* tell where it kept coming from.
*/
private fun takeRequestedNote(intent: Intent?): String? {
val id = intent?.getStringExtra(Reminders.EXTRA_NOTE_ID) ?: return null
intent.removeExtra(Reminders.EXTRA_NOTE_ID)
return id
}
}
/** Which screen is up. Exactly one at a time. */
@@ -72,9 +109,32 @@ private enum class Screen { BOARD, EDITOR, SYNC }
* already lives in view models.
*/
@Composable
private fun App(core: ThoughtSync) {
private fun App(
core: ThoughtSync,
requestedNote: MutableState<String?>,
) {
val context = LocalContext.current
val board: BoardViewModel = viewModel(factory = BoardViewModel.factory(core))
val board: BoardViewModel =
viewModel(
factory =
BoardViewModel.factory(core) {
// Any store write can have moved the next reminder. Called on
// the IO dispatcher by the view model, which is where it has to
// be — this reads every note carrying a reminder.
Reminders.refresh(context, core)
},
)
// Consumed, not just read: without clearing it, every later recomposition
// would reopen the same note and make the editor impossible to leave.
LaunchedEffect(requestedNote.value) {
requestedNote.value?.let {
board.openNoteById(it)
requestedNote.value = null
}
}
ReminderAlarms(core)
// A pull can rewrite every note the board is holding, so a sync that changed
// anything tells it to reload. Wired here, at the one place that owns both.
val sync: SyncViewModel =
@@ -173,6 +233,48 @@ private fun App(core: ThoughtSync) {
BackHandler(enabled = showingSync) { showingSync = false }
}
/**
* Keeping the alarm current, and asking to be allowed to ring it.
*
* The refresh runs on every return to the foreground rather than once at launch:
* a reminder can have been set on the desktop and pulled in while this app was
* backgrounded, and the alarm is derived from the store, not from what the UI last
* saw. It is cheap and idempotent by construction — see [Reminders.refresh].
*
* The permission is asked for on the first launch where a reminder actually
* exists. Android gives an app essentially one chance at this dialog, so spending
* it at first launch on an empty board — before the person has any idea what
* notifications this app would send — is spending it on nothing.
*/
@Composable
private fun ReminderAlarms(core: ThoughtSync) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// Off the main thread: this reads every note that has a reminder, and a phone
// holding a few hundred would drop frames doing it during a resume.
val refresh = { scope.launch(Dispatchers.IO) { Reminders.refresh(context, core) } }
val prompt =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
// Whatever the answer, re-derive: if it was yes, the reminders that
// could not be shown a moment ago can be shown now.
refresh()
}
ForegroundTransitions(onForeground = { refresh() }, onBackground = {})
LaunchedEffect(Unit) {
// TIRAMISU is where POST_NOTIFICATIONS became a runtime permission. Below
// it, notifications are granted at install and there is nothing to ask.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
val due = withContext(Dispatchers.IO) { Reminders.promptToNotifyDue(context, core) }
if (due) {
Reminders.markPromptShown(context)
prompt.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
/**
* Syncing without being asked.
*
@@ -234,45 +336,6 @@ private fun AutomaticSync(
}
}
/**
* Calls back when the app comes to the front and when it leaves.
*
* `ON_START`/`ON_STOP` and not `ON_RESUME`/`ON_PAUSE`, which is the same choice
* the editor's save-on-leave makes for the same reason: resume and pause fire for
* anything that merely covers the window — a permission dialog, the notification
* shade — and a sync per shade-pull is not automatic sync, it is a stutter.
*
* A single-Activity app, so the Activity's lifecycle is the app's. If a second
* Activity is ever added this needs `ProcessLifecycleOwner` instead, or rotating
* between them will read as leaving and returning.
*
* Both callbacks go through [rememberUpdatedState]: the observer is registered
* once, and without it the lambda would keep reading the first composition's
* state forever — deciding whether to push unsent notes from a snapshot taken
* before any note existed.
*/
@Composable
private fun ForegroundTransitions(
onForeground: () -> Unit,
onBackground: () -> Unit,
) {
val forward by rememberUpdatedState(onForeground)
val away by rememberUpdatedState(onBackground)
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> forward()
Lifecycle.Event.ON_STOP -> away()
else -> Unit
}
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
}
/**
* How stale the last sync has to be before opening the app triggers another.
*
@@ -0,0 +1,115 @@
package com.fabledsword.thoughtsync
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.fabledsword.thoughtsync.core.Note
/**
* What a due reminder looks like in the shade.
*
* Split from [Reminders] because the two answer different questions and change for
* different reasons: that one decides WHEN something should be said, this one
* decides how it is said and what can be done about it without opening the app.
*/
internal object ReminderNotification {
fun ensureChannel(context: Context) {
val channel =
NotificationChannel(
CHANNEL,
context.getString(R.string.reminder_channel),
// HIGH so a reminder can interrupt. Someone who asked to be
// reminded at a time has already said this may interrupt them;
// DEFAULT would leave it silent in the shade until next unlock.
NotificationManager.IMPORTANCE_HIGH,
).apply { description = context.getString(R.string.reminder_channel_description) }
context
.getSystemService(NotificationManager::class.java)
?.createNotificationChannel(channel)
}
/** Post one reminder. Returns whether it actually reached the shade. */
fun show(
context: Context,
note: Note,
): Boolean {
val manager = NotificationManagerCompat.from(context)
// Not marked as announced when this is false, so a reminder is not silently
// burned by being "delivered" to a device that cannot show it — turning
// notifications on later still surfaces it.
if (!manager.areNotificationsEnabled()) return false
val body = note.body.trim().takeIf { it.isNotEmpty() && it != note.displayTitle }
val builder =
NotificationCompat
.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(note.displayTitle)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(openIntent(context, note))
.addAction(
0,
context.getString(R.string.reminder_done),
action(context, note, ReminderReceiver.ACTION_DONE),
).addAction(
0,
context.getString(R.string.reminder_snooze_hour),
action(context, note, ReminderReceiver.ACTION_SNOOZE),
)
if (body != null) {
builder.setContentText(body).setStyle(NotificationCompat.BigTextStyle().bigText(body))
}
return runCatching {
manager.notify(note.id.hashCode(), builder.build())
true
}.getOrElse {
// POST_NOTIFICATIONS can be revoked between the check and the post.
Log.w(TAG, "could not post reminder", it)
false
}
}
private fun openIntent(
context: Context,
note: Note,
): PendingIntent =
PendingIntent.getActivity(
context,
note.id.hashCode(),
Intent(context, MainActivity::class.java)
.setAction(Intent.ACTION_VIEW)
.putExtra(Reminders.EXTRA_NOTE_ID, note.id)
// Reuse the running task rather than stacking a second copy of the
// app on top of itself; MainActivity picks the id up in onNewIntent.
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun action(
context: Context,
note: Note,
what: String,
): PendingIntent =
PendingIntent.getBroadcast(
context,
// Distinct per note AND per action, or the two would share one
// PendingIntent and Snooze would quietly perform Done.
(note.id + what).hashCode(),
Intent(context, ReminderReceiver::class.java)
.setAction(what)
.putExtra(Reminders.EXTRA_NOTE_ID, note.id),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private const val CHANNEL = "reminders"
private const val TAG = "ThoughtSyncReminders"
}
@@ -0,0 +1,72 @@
package com.fabledsword.thoughtsync
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Everything that happens to a reminder while the app is not on screen.
*
* Four arrivals, one ending: whatever came in, the reminder picture is recomputed
* and the next alarm is set. That is deliberate — it means no path here has to
* remember to reschedule, and the one that fires an alarm cannot leave the device
* with no alarm pending.
*
* - **[ACTION_DUE]** — the alarm went off. Announce what is due.
* - **[ACTION_DONE] / [ACTION_SNOOZE]** — a notification button. Write it to the
* store, drop the notification.
* - **`BOOT_COMPLETED`** — alarms do not survive a restart, so every reminder on
* the device would silently stop existing without this.
* - **`MY_PACKAGE_REPLACED`** — an app update cancels them the same way. This
* device installs by APK from its own server, so updates are routine.
*
* ## Threading
*
* `onReceive` runs on the main thread and the store is blocking SQLite, so the
* work goes to [Dispatchers.IO] under [goAsync]. Without `goAsync` the process
* becomes killable the moment `onReceive` returns, which for a reminder firing at
* 3am is precisely when nothing is holding it up.
*/
class ReminderReceiver : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent,
) {
val core = (context.applicationContext as? ThoughtSyncApplication)?.core ?: return
val action = intent.action
val noteId = intent.getStringExtra(Reminders.EXTRA_NOTE_ID)
val app = context.applicationContext
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
when (action) {
ACTION_DONE -> noteId?.let { Reminders.complete(app, core, it) }
ACTION_SNOOZE -> noteId?.let { Reminders.snooze(app, core, it) }
// The alarm and the two system broadcasts all want the same
// thing, which is simply: look at the store and act on it.
else -> Unit
}
Reminders.refresh(app, core)
} catch (e: Exception) {
// Broad on purpose. Nobody is present, so an escaping exception is
// a crash report for something the person never initiated — and
// every path in here has already logged its own failure.
Log.w(TAG, "reminder broadcast failed: $action", e)
} finally {
pending.finish()
}
}
}
companion object {
const val ACTION_DUE = "com.fabledsword.thoughtsync.REMINDER_DUE"
const val ACTION_DONE = "com.fabledsword.thoughtsync.REMINDER_DONE"
const val ACTION_SNOOZE = "com.fabledsword.thoughtsync.REMINDER_SNOOZE"
private const val TAG = "ThoughtSyncReminders"
}
}
@@ -0,0 +1,245 @@
package com.fabledsword.thoughtsync
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.ThoughtSync
import java.time.OffsetDateTime
/**
* Getting a reminder in front of someone at the time they asked for.
*
* ## One alarm, not one per reminder
*
* Only the EARLIEST future reminder is ever scheduled. When it fires, everything
* now due is announced and the next one is scheduled. A hundred reminders cost one
* alarm, and there is no bookkeeping to get wrong when a note is edited on another
* device and arrives by sync — [refresh] recomputes the whole picture from the
* store every time.
*
* ## AlarmManager, not WorkManager
*
* The background sync runs on WorkManager and is right to: nobody minds whether it
* happens at 3:05 or 3:19. A reminder minded very much. WorkManager's periodic
* floor is fifteen minutes and it batches work into maintenance windows, so
* "remind me at 09:00" would routinely arrive at 09:14 — which is not a reminder,
* it is a rebuke.
*
* Exactness is asked for and not depended on: on Android 12+ it is a permission
* the person can refuse, and refusing drops this to an inexact alarm rather than
* to nothing. A reminder a few minutes late still beats no reminder, and pestering
* someone into a settings screen before the feature works at all is the coercion
* this product does not do.
*/
object Reminders {
const val EXTRA_NOTE_ID = "note_id"
/**
* How long after its time a missed reminder is still worth announcing.
*
* The web uses fifteen minutes, because a tab that is open has been checking
* every forty-five seconds and anything older than that was almost certainly
* already seen. A phone can be switched off all night, so the equivalent
* question here — "could this plausibly not have been seen yet?" — has a much
* longer answer. Beyond a day it stops being a reminder and starts being
* archaeology; the note is still on the board, still marked overdue in red.
*/
private const val MISSED_WINDOW_MS = 24L * 60 * 60 * 1000
private const val SNOOZE_MINUTES = 60L
private const val TAG = "ThoughtSyncReminders"
/**
* Announce what is due, then schedule the next one.
*
* Safe to call as often as anything might have changed — after an edit, after
* a sync, at launch, at boot. It reads the whole reminder set each time and
* derives everything from it, so there is no incremental state to drift.
*/
fun refresh(
context: Context,
core: ThoughtSync,
) {
ReminderNotification.ensureChannel(context)
val notes =
runCatching { core.reminderNotes() }
.onFailure { Log.w(TAG, "could not read reminders", it) }
.getOrElse { return }
val now = System.currentTimeMillis()
val announced = Announced(context)
// Intersecting with what still exists prunes the record in the same step:
// a reminder that was completed, snoozed to a new time or deleted drops out
// on its own, so this set cannot grow without bound.
val live = notes.mapNotNull { key(it) }.toSet()
val seen = announced.keys().intersect(live).toMutableSet()
val due = notes.filter { at(it)?.let { ms -> ms <= now } == true }
if (!announced.primed) {
// First run on this device. Adopt everything already overdue SILENTLY:
// the storm case is linking a server and pulling months of history, and
// a hundred notifications the moment someone signs in is a good way to
// have them turn the feature off before it has ever been useful.
due.forEach { note -> key(note)?.let { seen += it } }
} else {
due.forEach { note ->
val k = key(note) ?: return@forEach
val overdueBy = now - (at(note) ?: return@forEach)
if (k !in seen && overdueBy <= MISSED_WINDOW_MS && ReminderNotification.show(context, note)) {
seen += k
}
}
}
announced.write(seen)
scheduleNext(context, notes, now)
}
/**
* Whether it is worth putting Android's notification prompt in front of someone.
*
* True only when there is a reminder that could actually fire and we have not
* asked before. Asking at launch on an empty board would be a dialog with no
* visible cause, which is how people learn to dismiss dialogs unread; asking
* again after a refusal is nagging, and the Reminders view carries a standing
* notice for anyone who changes their mind.
*/
fun promptToNotifyDue(
context: Context,
core: ThoughtSync,
): Boolean =
!Announced(context).askedToNotify &&
!NotificationManagerCompat.from(context).areNotificationsEnabled() &&
runCatching { core.reminderNotes().isNotEmpty() }.getOrDefault(false)
/** Remember that Android's prompt has been shown, whatever the answer was. */
fun markPromptShown(context: Context) = Announced(context).markAsked()
/** Clear the reminder, as the notification's Done action. */
fun complete(
context: Context,
core: ThoughtSync,
noteId: String,
) {
runCatching { core.completeReminder(noteId) }
.onFailure { Log.w(TAG, "could not complete reminder", it) }
ReminderNotification.dismiss(context, noteId)
}
/** Push the reminder an hour out, as the notification's Snooze action. */
fun snooze(
context: Context,
core: ThoughtSync,
noteId: String,
) {
runCatching { core.snoozeReminder(noteId, SNOOZE_MINUTES) }
.onFailure { Log.w(TAG, "could not snooze reminder", it) }
ReminderNotification.dismiss(context, noteId)
}
// ─────────────────────────────── scheduling ───────────────────────────────
private fun scheduleNext(
context: Context,
notes: List<Note>,
now: Long,
) {
val alarms = context.getSystemService(AlarmManager::class.java) ?: return
val fire =
PendingIntent.getBroadcast(
context,
0,
Intent(context, ReminderReceiver::class.java).setAction(ReminderReceiver.ACTION_DUE),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val next = notes.mapNotNull { at(it) }.filter { it > now }.minOrNull()
if (next == null) {
alarms.cancel(fire)
return
}
// RTC_WAKEUP: reminders are wall-clock times, and the point is to wake a
// sleeping phone. ELAPSED_REALTIME would drift against the clock the person
// actually set the reminder against.
runCatching {
if (canBeExact(alarms)) {
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, next, fire)
} else {
alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, next, fire)
}
}.onFailure {
// setExact can still throw if the permission was revoked between the
// check and the call. Falling back beats losing the reminder entirely.
Log.w(TAG, "exact alarm refused, falling back to inexact", it)
runCatching { alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, next, fire) }
}
}
/**
* Whether this device will let us fire at the exact minute.
*
* Below Android 12 there was no permission and exact alarms always worked.
* From 12 it is grantable and from 13 it is denied by default, so this is a
* question with a real answer rather than a formality.
*/
fun canBeExact(alarms: AlarmManager): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()
// ────────────────────────────── bookkeeping ──────────────────────────────
/** Epoch millis of a note's reminder, or null if it has none we can read. */
private fun at(note: Note): Long? =
note.remindAt?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
}
/**
* Identity of one OCCURRENCE, not of the note.
*
* The time is part of it so that snoozing — which rewrites `remind_at` — is a
* new thing to announce rather than one already dealt with. Same key the web
* store uses, for the same reason.
*/
private fun key(note: Note): String? = note.remindAt?.let { "${note.id}@$it" }
}
/** Which reminder occurrences have already been put in front of someone. */
private class Announced(
context: Context,
) {
private val prefs =
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
/** False only before the very first [Reminders.refresh] on this install. */
val primed: Boolean get() = prefs.getBoolean(KEY_PRIMED, false)
// Copied: the set from getStringSet must not be mutated, and the docs are
// explicit that doing so corrupts what is stored.
fun keys(): Set<String> = prefs.getStringSet(KEY_SEEN, emptySet())?.toSet().orEmpty()
fun write(keys: Set<String>) {
prefs
.edit()
.putStringSet(KEY_SEEN, keys)
.putBoolean(KEY_PRIMED, true)
.apply()
}
/** Survives a restart, so the prompt is a one-off rather than once per launch. */
val askedToNotify: Boolean get() = prefs.getBoolean(KEY_ASKED, false)
fun markAsked() = prefs.edit().putBoolean(KEY_ASKED, true).apply()
private companion object {
const val FILE = "thoughtsync-reminders"
const val KEY_SEEN = "announced"
const val KEY_PRIMED = "primed"
const val KEY_ASKED = "asked_to_notify"
}
}
@@ -47,6 +47,12 @@ class SyncWorker(
if (core.syncStatus().linked) {
val outcome = core.syncNow()
Log.i(TAG, "background sync at ${outcome.status.lastSyncAt}")
// A pull can have brought in a reminder set on another device, or
// moved one this phone already knew about. The alarm is derived
// from the store, so it has to be re-derived whenever the store
// changed underneath it — otherwise a reminder made at a desk
// never rings on the phone until the app is next opened.
Reminders.refresh(applicationContext, core)
}
Result.success()
} catch (e: Exception) {
@@ -117,6 +117,10 @@ fun BoardScreen(
ErrorBanner(message = message, onDismiss = sync.onDismissError)
}
// Only where someone is already thinking about reminders. On the
// main board it would nag people who have never set one.
if (state.destination == Destination.Reminders) ReminderNotice()
val pull = rememberPullToRefreshState()
Box(
modifier =
@@ -423,5 +427,8 @@ fun StoreUnavailableScreen(reason: String?) {
}
private const val BOARD_COLUMNS = 2
private val GUTTER = 12.dp
// Not private: the reminder notice is board content and has to line up with the
// search bar and the cards, so it shares the board's gutter rather than guessing.
internal val GUTTER = 12.dp
private val SEARCH_RADIUS = 28.dp
@@ -93,6 +93,16 @@ data class BoardState(
@Suppress("TooManyFunctions")
class BoardViewModel(
private val core: ThoughtSync,
/**
* Called after any write that could have moved a reminder.
*
* The alarm is derived from the store, so anything that edits the store can
* invalidate it — setting a time, completing one, trashing the note it is on.
* Wired as a callback rather than reaching for a Context from a view model,
* which is how view models come to leak Activities. Same shape as the sync
* view model's `onStoreChanged`.
*/
private val onRemindersChanged: () -> Unit = {},
) : ViewModel() {
var state by mutableStateOf(BoardState())
private set
@@ -212,6 +222,11 @@ class BoardViewModel(
} else {
state.notes
}
// A capture sheet can carry a reminder in its text one day;
// more to the point, this is a store write and the rule here is
// that every store write re-derives the alarm rather than each
// call site deciding whether its particular write could matter.
withContext(Dispatchers.IO) { onRemindersChanged() }
state.copy(notes = notes, saving = false, error = null)
} catch (e: Exception) {
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
@@ -221,6 +236,20 @@ class BoardViewModel(
// ─────────────────────────────── the editor ──────────────────────────────
/**
* Open a note by id, for a notification tap.
*
* Loads it fresh rather than searching the board's list: the board may be
* showing Trash, a label, or search results, and a reminder can fire for a note
* that is in none of them.
*/
fun openNoteById(id: String) {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { core.getNote(id) } }
.onSuccess { state = state.copy(editing = it) }
}
}
fun openNote(note: Note) {
state = state.copy(editing = note)
}
@@ -379,6 +408,10 @@ class BoardViewModel(
} else {
withContext(Dispatchers.IO) { load(state.destination) }
}
// On IO, not here: re-deriving the alarm reads every note
// that carries a reminder, and this line runs on the main
// thread — the coroutine is back from its withContext by now.
withContext(Dispatchers.IO) { onRemindersChanged() }
state.copy(
notes = notes,
editing = if (closeEditor) null else updated ?: state.editing,
@@ -408,10 +441,14 @@ class BoardViewModel(
private const val VIEW_ARCHIVE = "archived"
private const val VIEW_TRASH = "trash"
fun factory(core: ThoughtSync): ViewModelProvider.Factory =
fun factory(
core: ThoughtSync,
onRemindersChanged: () -> Unit,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = BoardViewModel(core) as T
override fun <T : ViewModel> create(modelClass: Class<T>): T =
BoardViewModel(core, onRemindersChanged) as T
}
}
}
@@ -0,0 +1,52 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
/**
* Calls back when the app comes to the front and when it leaves.
*
* `ON_START`/`ON_STOP` and not `ON_RESUME`/`ON_PAUSE`, which is the same choice
* the editor's save-on-leave makes for the same reason: resume and pause fire for
* anything that merely covers the window — a permission dialog, the notification
* shade — and a sync per shade-pull is not automatic sync, it is a stutter.
*
* A single-Activity app, so the Activity's lifecycle is the app's. If a second
* Activity is ever added this needs `ProcessLifecycleOwner` instead, or rotating
* between them will read as leaving and returning.
*
* Two callers, wanting opposite halves of it: automatic sync uses the return to
* decide whether to fetch, and the reminder notice uses it to re-read a
* permission the person may have just changed in the system settings.
*
* Both callbacks go through [rememberUpdatedState]: the observer is registered
* once, and without it the lambda would keep reading the first composition's
* state forever — deciding whether to push unsent notes from a snapshot taken
* before any note existed.
*/
@Composable
fun ForegroundTransitions(
onForeground: () -> Unit,
onBackground: () -> Unit,
) {
val forward by rememberUpdatedState(onForeground)
val away by rememberUpdatedState(onBackground)
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> forward()
Lifecycle.Event.ON_STOP -> away()
else -> Unit
}
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
}
@@ -53,6 +53,15 @@ fun Notice(
title: String,
body: String,
onDismiss: (() -> Unit)? = null,
/**
* A way to FIX what the notice describes, when there is one.
*
* Separate from [onDismiss] because they are opposites: dismissing accepts the
* situation, acting changes it. A notice about a permission has an action and
* no dismiss — acknowledging a reminder that cannot ring does not make it ring.
*/
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
) {
Panel(tone = tone) {
Text(
@@ -61,6 +70,9 @@ fun Notice(
fontWeight = FontWeight.SemiBold,
)
Text(text = body, style = MaterialTheme.typography.bodyMedium)
if (actionLabel != null && onAction != null) {
TextButton(onClick = onAction) { Text(actionLabel) }
}
onDismiss?.let {
TextButton(onClick = it) { Text(stringResource(R.string.error_dismiss)) }
}
@@ -0,0 +1,96 @@
package com.fabledsword.thoughtsync.ui
import android.app.AlarmManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.Reminders
/**
* Says so when a reminder would not actually reach anyone.
*
* Both conditions here are ones Android can put the app into at any time and
* never tells it about: notifications switched off in system settings, and exact
* alarms refused. Either one turns reminders into something that silently does
* nothing, and a feature that silently does nothing is worse than one that is
* plainly absent — the person keeps setting reminders and keeps not getting them.
*
* Shown only on the Reminders view, which is where somebody is already thinking
* about this. Putting it on the main board would nag people who have never set a
* reminder at all.
*
* Re-read on every return to the app, because the fix happens in a system screen
* this app cannot observe: without that, someone would grant the permission, come
* back, and still be looking at a warning telling them they had not.
*/
@Composable
fun ReminderNotice() {
val context = LocalContext.current
var canNotify by remember { mutableStateOf(notificationsAllowed(context)) }
var canBeExact by remember { mutableStateOf(exactAllowed(context)) }
ForegroundTransitions(
onForeground = {
canNotify = notificationsAllowed(context)
canBeExact = exactAllowed(context)
},
onBackground = {},
)
if (canNotify && canBeExact) return
Column(modifier = Modifier.padding(horizontal = GUTTER, vertical = 4.dp)) {
if (!canNotify) {
Notice(
tone = Tone.WARN,
title = stringResource(R.string.reminder_notifications_blocked_title),
body = stringResource(R.string.reminder_notifications_blocked_body),
actionLabel = stringResource(R.string.reminder_open_settings),
onAction = { context.startActivity(appNotificationSettings(context)) },
)
}
// Only worth raising once notifications work at all: told both at once, the
// second is noise about the punctuality of something that is not arriving.
if (canNotify && !canBeExact) {
Notice(
tone = Tone.WARN,
title = stringResource(R.string.reminder_inexact_title),
body = stringResource(R.string.reminder_inexact_body),
actionLabel = stringResource(R.string.reminder_allow_exact),
onAction = { context.startActivity(exactAlarmSettings(context)) },
)
}
}
}
private fun notificationsAllowed(context: Context): Boolean =
NotificationManagerCompat.from(context).areNotificationsEnabled()
private fun exactAllowed(context: Context): Boolean {
val alarms = context.getSystemService(AlarmManager::class.java) ?: return true
return Reminders.canBeExact(alarms)
}
private fun appNotificationSettings(context: Context): Intent =
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
private fun exactAlarmSettings(context: Context): Intent =
Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
.setData(Uri.fromParts("package", context.packageName, null))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The status-bar icon for a reminder.
A flat white silhouette on transparency, because that is the only thing Android
renders here — a status-bar icon is used as a MASK, so the launcher icon (which
is a full-colour adaptive asset) would come out as a solid white blob. This is
the Material bell, matching the icon the editor's reminder button already uses,
so the same idea wears the same shape in both places.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
</vector>
@@ -112,6 +112,14 @@
<string name="sync_disconnect">Disconnect</string>
<string name="sync_disconnect_title">Stop syncing with this server?</string>
<string name="sync_disconnect_body">Your notes stay on this device, and the copy on the server is left alone. This device\'s access token is revoked, so it can\'t be used to reach the server again.</string>
<string name="reminder_channel">Reminders</string>
<string name="reminder_channel_description">Notifies you when a note\'s reminder is due.</string>
<string name="reminder_notifications_blocked_title">Reminders can\'t notify you</string>
<string name="reminder_notifications_blocked_body">Notifications are turned off for ThoughtSync, so reminders will only show here on the board.</string>
<string name="reminder_open_settings">Open settings</string>
<string name="reminder_inexact_title">Reminders may arrive late</string>
<string name="reminder_inexact_body">Without permission for exact alarms, Android delivers reminders when it next wakes the phone — usually within a few minutes, sometimes longer.</string>
<string name="reminder_allow_exact">Allow exact timing</string>
<string name="sync_automatic">Sync automatically</string>
<string name="sync_automatic_on">Checks about every 15 minutes, and whenever you open the app.</string>
<string name="sync_automatic_off">Only when you pull the board down or tap Sync now.</string>
+4
View File
@@ -61,6 +61,9 @@ exceptions:
# exception is a crash report for a job the person never asked for. Every
# realistic failure there (no route, server down, token rotating) has the
# same right answer, which is Result.retry().
# * the reminder BroadcastReceiver — same argument, one step worse: it can be
# woken at 3am by an alarm or by BOOT_COMPLETED, and every path inside it
# has already logged its own failure by the time this catches anything.
#
# Scoped to those paths rather than disabled globally: elsewhere the rule is
# right and still applies.
@@ -68,3 +71,4 @@ exceptions:
- "**/ui/**"
- "**/ThoughtSyncApplication.kt"
- "**/SyncWorker.kt"
- "**/ReminderReceiver.kt"