From 785ebdba5929ad9c23ee4f35f5bc50a88d43e12b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 19 Aug 2026 20:06:05 -0400 Subject: [PATCH] android: reminders that actually reach you (M12 step 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- android/app/src/main/AndroidManifest.xml | 41 +++ .../fabledsword/thoughtsync/MainActivity.kt | 157 +++++++---- .../thoughtsync/ReminderNotification.kt | 115 ++++++++ .../thoughtsync/ReminderReceiver.kt | 72 +++++ .../com/fabledsword/thoughtsync/Reminders.kt | 245 ++++++++++++++++++ .../com/fabledsword/thoughtsync/SyncWorker.kt | 6 + .../fabledsword/thoughtsync/ui/BoardScreen.kt | 9 +- .../thoughtsync/ui/BoardViewModel.kt | 41 ++- .../thoughtsync/ui/ForegroundTransitions.kt | 52 ++++ .../com/fabledsword/thoughtsync/ui/Panel.kt | 12 + .../thoughtsync/ui/ReminderNotice.kt | 96 +++++++ .../src/main/res/drawable/ic_notification.xml | 20 ++ android/app/src/main/res/values/strings.xml | 8 + android/config/detekt.yml | 4 + 14 files changed, 828 insertions(+), 50 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ReminderNotification.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ReminderReceiver.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/Reminders.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/ForegroundTransitions.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/ReminderNotice.kt create mode 100644 android/app/src/main/res/drawable/ic_notification.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0bf3098..7f12efb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -46,6 +46,28 @@ warning when the probed address is http://, BEFORE any credential field appears. See SyncScreen.kt. --> + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt index 8cf8775..33a45cd 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt @@ -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(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, +) { 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. * diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ReminderNotification.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ReminderNotification.kt new file mode 100644 index 0000000..c9ada48 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ReminderNotification.kt @@ -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" +} diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ReminderReceiver.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ReminderReceiver.kt new file mode 100644 index 0000000..4a3d151 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ReminderReceiver.kt @@ -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" + } +} diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/Reminders.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/Reminders.kt new file mode 100644 index 0000000..34686c8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/Reminders.kt @@ -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, + 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 = prefs.getStringSet(KEY_SEEN, emptySet())?.toSet().orEmpty() + + fun write(keys: Set) { + 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" + } +} diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/SyncWorker.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/SyncWorker.kt index 9a19cff..e788f0e 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/SyncWorker.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/SyncWorker.kt @@ -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) { diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt index 2a68a0e..a20c4b3 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt index 4466fcb..037310e 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt @@ -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 create(modelClass: Class): T = BoardViewModel(core) as T + override fun create(modelClass: Class): T = + BoardViewModel(core, onRemindersChanged) as T } } } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ForegroundTransitions.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ForegroundTransitions.kt new file mode 100644 index 0000000..dc907f6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ForegroundTransitions.kt @@ -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) } + } +} diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/Panel.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/Panel.kt index 78d5c53..c4c7fd9 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/Panel.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/Panel.kt @@ -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)) } } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ReminderNotice.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ReminderNotice.kt new file mode 100644 index 0000000..2fa94dd --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ReminderNotice.kt @@ -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) diff --git a/android/app/src/main/res/drawable/ic_notification.xml b/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..5be4456 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 148fb79..15c5fc3 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -112,6 +112,14 @@ Disconnect Stop syncing with this server? 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. + Reminders + Notifies you when a note\'s reminder is due. + Reminders can\'t notify you + Notifications are turned off for ThoughtSync, so reminders will only show here on the board. + Open settings + Reminders may arrive late + Without permission for exact alarms, Android delivers reminders when it next wakes the phone — usually within a few minutes, sometimes longer. + Allow exact timing Sync automatically Checks about every 15 minutes, and whenever you open the app. Only when you pull the board down or tap Sync now. diff --git a/android/config/detekt.yml b/android/config/detekt.yml index 89772e9..0d14ffc 100644 --- a/android/config/detekt.yml +++ b/android/config/detekt.yml @@ -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"