feat(todo): reminders on arriving and leaving, on Android (ADR 0033, D32)

The system's proximity alerts, 150 m, no Google services, with a two-minute
dwell so that driving past doesn't remind (LocationDwell, with JUnit tests):
once a task until it changes, an alternative that reminded stopping the
task's others. Kept in the app's private preferences, registered anew after
a restart or an update. The position first, then "Allow all the time".
A debug build logs to `adb logcat -s HalkyonLocation`, and installs beside
the release as app.halkyon.todo.debug, "Todo Debug". With both installed
Android asks which answers SimpleLogin's way back, the scheme the instance
allows being one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-05 15:24:01 +01:00
commit e669c8d5b0
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 567 additions and 1 deletions

View file

@ -29,6 +29,7 @@ android {
// flag during build. // flag during build.
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
manifestPlaceholders["appLabel"] = "Halkyon Todo"
} }
// The app's own key signs release builds when tool/release.dart hands it over // The app's own key signs release builds when tool/release.dart hands it over
@ -50,6 +51,14 @@ android {
} }
buildTypes { buildTypes {
// A debug build installs beside the release from F-Droid, whose key
// differs (ADR 0033, D32): another ID, and its own name in the launcher.
// Both then answer SimpleLogin's way back, app.halkyon.todo://, which
// the instance allows alone, so Android asks which: the debug build.
debug {
applicationIdSuffix = ".debug"
manifestPlaceholders["appLabel"] = "Todo Debug"
}
release { release {
signingConfig = signingConfig =
signingConfigs.findByName("release") ?: signingConfigs.getByName("debug") signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
@ -85,4 +94,5 @@ flutter {
dependencies { dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
testImplementation("junit:junit:4.13.2")
} }

View file

@ -21,13 +21,21 @@
which Android 11 and later only shows the app when it is declared which Android 11 and later only shows the app when it is declared
below. --> below. -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/>
<!-- Reminders at a place (ADR 0033, D32), on arriving or leaving: the
system's proximity alerts, which need the precise position, with the
app closed too. Fine is asked for as a person chooses such a reminder,
then, told why, "Allow all the time" in the system's settings. The
position stays on the device; the map centres on it. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<!-- No backup and no transfer to another phone (ADR 0033, step 5, as <!-- No backup and no transfer to another phone (ADR 0033, step 5, as
Train's ADR 0030, D53): the reminders scheduled, with their tasks' Train's ADR 0030, D53): the reminders scheduled, with their tasks'
titles, are kept unsealed. Through Android 11 allowBackup and titles, are kept unsealed. Through Android 11 allowBackup and
fullBackupContent stop both, from 12 on the extraction rules stop the fullBackupContent stop both, from 12 on the extraction rules stop the
transfer. --> transfer. -->
<application <application
android:label="Halkyon Todo" android:label="${appLabel}"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:allowBackup="false" android:allowBackup="false"
@ -81,6 +89,15 @@
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/> <action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- Places crossed and the checks after (ADR 0033, D32), and their alerts
registered anew after a restart or an update. Sent by the system
through this app's own pending intents, so not exported. -->
<receiver android:exported="false" android:name=".LocationReminderReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
</intent-filter>
</receiver>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

View file

@ -0,0 +1,37 @@
package app.halkyon.todo
/**
* Whether a place reminds now (ADR 0033, D32): the pure half of the dwell, so
* that driving past one EDEKA on the way to the next doesn't. The system says
* when the phone crosses the circle; [CHECK_AFTER_MILLIS] later this decides.
*/
object LocationDwell {
/** The circle around a place. */
const val RADIUS_METRES = 150f
/** How long after crossing the circle the phone must still be on that side. */
const val CHECK_AFTER_MILLIS = 120_000L
/** A fix newer than this says better than the system's last event. */
const val FRESH_MILLIS = 90_000L
/** A fix is still inside up to here: its error shouldn't undo an arrival. */
const val INSIDE_METRES = 200f
/**
* Whether the phone is inside the circle: by a fresh fix, [distance] metres
* away and [age] milliseconds old, else as the system last said, [event].
*/
fun inside(event: Boolean, distance: Float?, age: Long?): Boolean =
if (distance != null && age != null && age <= FRESH_MILLIS) {
distance <= (if (event) INSIDE_METRES else RADIUS_METRES)
} else {
event
}
/** Whether a crossing just seen, [entering] or not, needs a check at all. */
fun wanted(arrive: Boolean, entering: Boolean): Boolean = arrive == entering
/** Whether to notify at the check: still inside on arriving, still outside on leaving. */
fun notify(arrive: Boolean, inside: Boolean): Boolean = arrive == inside
}

View file

@ -0,0 +1,41 @@
package app.halkyon.todo
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.location.LocationManager
/**
* What the system tells of the places watched (ADR 0033, D32): the phone
* crossing a circle, the check after it, and a restart or an update, after
* which the alerts are registered anew.
*/
class LocationReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> {
LocationReminders.log(context, "registered anew: ${intent.action}")
LocationReminders.registerAll(context)
}
PROXIMITY, CHECK -> {
val id = intent.getIntExtra(ID, 0)
val watch = LocationReminders.watches(context).find { it.id == id } ?: return
if (intent.action == CHECK) {
LocationReminders.check(context, watch)
} else if (intent.hasExtra(LocationManager.KEY_PROXIMITY_ENTERING)) {
LocationReminders.crossed(
context,
watch,
intent.getBooleanExtra(LocationManager.KEY_PROXIMITY_ENTERING, false),
)
}
}
}
}
companion object {
const val PROXIMITY = "app.halkyon.todo.LOCATION_PROXIMITY"
const val CHECK = "app.halkyon.todo.LOCATION_CHECK"
const val ID = "id"
}
}

View file

@ -0,0 +1,355 @@
package app.halkyon.todo
import android.Manifest
import android.app.Activity
import android.app.AlarmManager
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.net.Uri
import android.os.Build
import android.os.SystemClock
import android.provider.Settings
import android.util.Log
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import org.json.JSONArray
import org.json.JSONObject
/** A place watched, as the app's `LocationWatch` writes it. */
data class LocationWatch(
val id: Int,
val taskId: String,
val stamp: Int,
val title: String,
val body: String,
val lat: Double,
val lon: Double,
val arrive: Boolean,
) {
fun json(): JSONObject = JSONObject()
.put("id", id).put("taskId", taskId).put("stamp", stamp)
.put("title", title).put("body", body)
.put("lat", lat).put("lon", lon).put("arrive", arrive)
/** What marks this task as having reminded, until it changes. */
val done get() = "$taskId:$stamp"
companion object {
fun of(json: JSONObject) = LocationWatch(
id = json.getInt("id"),
taskId = json.getString("taskId"),
stamp = json.getInt("stamp"),
title = json.getString("title"),
body = json.getString("body"),
lat = json.getDouble("lat"),
lon = json.getDouble("lon"),
arrive = json.getBoolean("arrive"),
)
}
}
/**
* Reminders at a place (ADR 0033, D32): the system's proximity alerts, which
* need no Google services and run on the device, as the instance reads no
* task. What they need is kept in the app's private preferences, unsealed as
* the reminders scheduled are (`allowBackup` is off): the places, which tasks
* have reminded, and whether the phone is inside each.
*/
object LocationReminders {
private const val CHANNEL = "app.halkyon.todo/location_reminders"
private const val STORE = "halkyon_location"
private const val PERMISSION_REQUEST = 4711
private const val NOTIFICATION_CHANNEL = "reminders"
private const val TAG = "HalkyonLocation"
private var asking: ((Activity) -> Unit)? = null
/** Whether this process has made sure of the alerts: a force stop drops them unseen. */
private var sure = false
/** The debug build's log of enters, checks and fires: `adb logcat -s HalkyonLocation`. */
fun log(context: Context, message: String) {
if (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
Log.i(TAG, message)
}
}
private fun store(context: Context): SharedPreferences =
context.getSharedPreferences(STORE, Context.MODE_PRIVATE)
fun attach(activity: Activity, messenger: BinaryMessenger) {
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"replace" -> {
val watches = (call.argument<List<Map<String, Any>>>("watches") ?: emptyList())
.map { LocationWatch.of(JSONObject(it)) }
replace(activity, watches, call.argument<String>("channel").orEmpty())
result.success(null)
}
"access" -> {
registerMissing(activity)
result.success(access(activity))
}
"askWhileInUse" -> ask(
activity,
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
result,
)
"askAlways" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ask(activity, arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), result)
} else {
result.success(access(activity))
}
"openSettings" -> {
activity.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${activity.packageName}")),
)
result.success(null)
}
"lastKnown" -> result.success(lastKnown(activity)?.let { listOf(it.latitude, it.longitude) })
else -> result.notImplemented()
}
}
}
private fun ask(activity: Activity, permissions: Array<String>, result: MethodChannel.Result) {
if (asking != null) {
result.success(access(activity))
return
}
asking = { asked ->
registerMissing(asked)
result.success(access(asked))
}
activity.requestPermissions(permissions, PERMISSION_REQUEST)
}
/** Answers what [ask] started. */
fun onPermissionResult(activity: Activity, requestCode: Int) {
if (requestCode != PERMISSION_REQUEST) return
asking?.invoke(activity)
asking = null
}
private fun granted(context: Context, permission: String) =
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
private fun access(context: Context): String = when {
!granted(context, Manifest.permission.ACCESS_FINE_LOCATION) -> "none"
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q -> "always"
granted(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) -> "always"
else -> "whileInUse"
}
fun watches(context: Context): List<LocationWatch> {
val kept = JSONArray(store(context).getString("watches", "[]"))
return List(kept.length()) { LocationWatch.of(kept.getJSONObject(it)) }
}
private fun fired(context: Context): Set<String> =
store(context).getStringSet("fired", emptySet()).orEmpty()
private fun registered(context: Context): Set<String> =
store(context).getStringSet("registered", emptySet()).orEmpty()
/** The plan: what left it stops, what is new is registered. */
private fun replace(context: Context, watches: List<LocationWatch>, channel: String) {
val before = watches(context)
val manager = context.getSystemService(LocationManager::class.java)
val ids = watches.map { it.id }.toSet()
for (watch in before) {
if (watch.id !in ids) stop(context, manager, watch)
}
val dones = watches.map { it.done }.toSet()
store(context).edit()
.putString("watches", JSONArray(watches.map { it.json() }).toString())
.putString("channel", channel)
.putStringSet("fired", fired(context).filter { it in dones }.toSet())
.apply()
log(context, "plan: ${watches.size} places, ${before.size} before")
val waiting = watches.filter { it.done !in fired(context) }
// A task that has reminded and then changes reminds anew; one that hasn't
// changed stays silent, its alerts off.
for (watch in watches - waiting.toSet()) stop(context, manager, watch)
if (sure) {
registerMissing(context)
} else {
sure = true
registerAll(context)
}
}
/** Those not yet registered, as the permission has come since. */
private fun registerMissing(context: Context) {
if (!granted(context, Manifest.permission.ACCESS_FINE_LOCATION)) {
store(context).edit().remove("registered").apply()
return
}
val known = registered(context)
register(context, watches(context).filter { it.id.toString() !in known && it.done !in fired(context) })
}
/** All the plan's anew: the system forgets its alerts on a restart or an update. */
fun registerAll(context: Context) {
store(context).edit().remove("registered").apply()
register(context, watches(context).filter { it.done !in fired(context) })
}
private fun register(context: Context, watches: List<LocationWatch>) {
if (watches.isEmpty()) return
if (!granted(context, Manifest.permission.ACCESS_FINE_LOCATION)) {
log(context, "not registered: no permission")
return
}
val manager = context.getSystemService(LocationManager::class.java)
val added = mutableSetOf<String>()
for (watch in watches) {
try {
manager.addProximityAlert(
watch.lat,
watch.lon,
LocationDwell.RADIUS_METRES,
-1L,
proximityIntent(context, watch.id),
)
added += watch.id.toString()
log(context, "registered ${watch.id} ${watch.title} (${if (watch.arrive) "arrive" else "leave"})")
} catch (error: SecurityException) {
log(context, "not registered ${watch.id}: $error")
}
}
store(context).edit().putStringSet("registered", registered(context) + added).apply()
}
private fun stop(context: Context, manager: LocationManager, watch: LocationWatch) {
manager.removeProximityAlert(proximityIntent(context, watch.id))
context.getSystemService(AlarmManager::class.java).cancel(checkIntent(context, watch.id))
store(context).edit()
.remove("inside ${watch.id}")
.putStringSet("registered", registered(context) - watch.id.toString())
.apply()
}
/** Mutable: the system adds to it whether the phone entered. */
private fun proximityIntent(context: Context, id: Int): PendingIntent = PendingIntent.getBroadcast(
context,
id,
Intent(context, LocationReminderReceiver::class.java)
.setAction(LocationReminderReceiver.PROXIMITY)
.putExtra(LocationReminderReceiver.ID, id),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
)
private fun checkIntent(context: Context, id: Int): PendingIntent = PendingIntent.getBroadcast(
context,
id,
Intent(context, LocationReminderReceiver::class.java)
.setAction(LocationReminderReceiver.CHECK)
.putExtra(LocationReminderReceiver.ID, id),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** The phone crossed [watch]'s circle: a check later on if that's what it reminds of. */
fun crossed(context: Context, watch: LocationWatch, entering: Boolean) {
store(context).edit().putBoolean("inside ${watch.id}", entering).apply()
log(context, "${if (entering) "entered" else "left"} ${watch.id} ${watch.title}")
val alarms = context.getSystemService(AlarmManager::class.java)
val check = checkIntent(context, watch.id)
if (!LocationDwell.wanted(watch.arrive, entering)) {
alarms.cancel(check)
log(context, "check of ${watch.id} cancelled")
return
}
val at = SystemClock.elapsedRealtime() + LocationDwell.CHECK_AFTER_MILLIS
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarms.canScheduleExactAlarms()) {
alarms.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, at, check)
} else {
alarms.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, at, check)
}
log(context, "check of ${watch.id} in ${LocationDwell.CHECK_AFTER_MILLIS / 1000} s")
}
/** The check: reminds if the phone is still on that side. */
fun check(context: Context, watch: LocationWatch) {
if (watch.done in fired(context)) return
val event = store(context).getBoolean("inside ${watch.id}", false)
val fix = lastKnown(context)
val distance = fix?.let {
val metres = FloatArray(1)
Location.distanceBetween(it.latitude, it.longitude, watch.lat, watch.lon, metres)
metres[0]
}
val age = fix?.let { (SystemClock.elapsedRealtimeNanos() - it.elapsedRealtimeNanos) / 1_000_000 }
val inside = LocationDwell.inside(event, distance, age)
val notify = LocationDwell.notify(watch.arrive, inside)
log(context, "check ${watch.id}: event $event, $distance m, $age ms old, inside $inside, notify $notify")
if (notify) fire(context, watch)
}
/** Reminds once: the task's other places stop until it changes. */
private fun fire(context: Context, watch: LocationWatch) {
val manager = context.getSystemService(LocationManager::class.java)
for (other in watches(context)) {
if (other.taskId == watch.taskId) stop(context, manager, other)
}
store(context).edit().putStringSet("fired", fired(context) + watch.done).apply()
val notifications = context.getSystemService(NotificationManager::class.java)
if (notifications.getNotificationChannel(NOTIFICATION_CHANNEL) == null) {
notifications.createNotificationChannel(
NotificationChannel(
NOTIFICATION_CHANNEL,
store(context).getString("channel", null) ?: "Task reminders",
NotificationManager.IMPORTANCE_HIGH,
),
)
}
val open = PendingIntent.getActivity(
context,
watch.id,
Intent(context, MainActivity::class.java).setFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP,
),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
notifications.notify(
watch.id,
Notification.Builder(context, NOTIFICATION_CHANNEL)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(watch.title)
.setContentText(watch.body)
.setVisibility(Notification.VISIBILITY_PRIVATE)
.setContentIntent(open)
.setAutoCancel(true)
.build(),
)
log(context, "fired ${watch.id} ${watch.title}")
}
/** The newest position any provider knows; null without the permission. */
private fun lastKnown(context: Context): Location? {
if (!granted(context, Manifest.permission.ACCESS_FINE_LOCATION) &&
!granted(context, Manifest.permission.ACCESS_COARSE_LOCATION)
) {
return null
}
val manager = context.getSystemService(LocationManager::class.java)
return manager.getProviders(true)
.mapNotNull { provider ->
try {
manager.getLastKnownLocation(provider)
} catch (_: SecurityException) {
null
}
}
.maxByOrNull { it.elapsedRealtimeNanos }
}
}

View file

@ -9,5 +9,15 @@ class MainActivity : FlutterActivity() {
// Secrets halkyon_app shows kept out of screenshots and recents. // Secrets halkyon_app shows kept out of screenshots and recents.
ScreenGuard.attach(this, flutterEngine.dartExecutor.binaryMessenger) ScreenGuard.attach(this, flutterEngine.dartExecutor.binaryMessenger)
SensitiveClipboard.attach(this, flutterEngine.dartExecutor.binaryMessenger) SensitiveClipboard.attach(this, flutterEngine.dartExecutor.binaryMessenger)
LocationReminders.attach(this, flutterEngine.dartExecutor.binaryMessenger)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
LocationReminders.onPermissionResult(this, requestCode)
} }
} }

View file

@ -0,0 +1,55 @@
package app.halkyon.todo
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class LocationDwellTest {
@Test
fun `arriving wants entering, leaving wants exiting`() {
assertTrue(LocationDwell.wanted(arrive = true, entering = true))
assertFalse(LocationDwell.wanted(arrive = true, entering = false))
assertTrue(LocationDwell.wanted(arrive = false, entering = false))
assertFalse(LocationDwell.wanted(arrive = false, entering = true))
}
@Test
fun `driving past doesn't remind`() {
val inside = LocationDwell.inside(event = false, distance = null, age = null)
assertFalse(LocationDwell.notify(arrive = true, inside = inside))
}
@Test
fun `still inside two minutes later reminds of arriving`() {
val inside = LocationDwell.inside(event = true, distance = 80f, age = 10_000L)
assertTrue(LocationDwell.notify(arrive = true, inside = inside))
}
@Test
fun `back inside doesn't remind of leaving`() {
val inside = LocationDwell.inside(event = true, distance = null, age = null)
assertFalse(LocationDwell.notify(arrive = false, inside = inside))
}
@Test
fun `still outside reminds of leaving`() {
val inside = LocationDwell.inside(event = false, distance = 400f, age = 5_000L)
assertTrue(LocationDwell.notify(arrive = false, inside = inside))
}
@Test
fun `a fresh fix outweighs the last event`() {
assertFalse(LocationDwell.inside(event = true, distance = 500f, age = 5_000L))
assertTrue(LocationDwell.inside(event = false, distance = 100f, age = 5_000L))
}
@Test
fun `a stale fix doesn't`() {
assertTrue(LocationDwell.inside(event = true, distance = 500f, age = 200_000L))
}
@Test
fun `an inaccurate fix just beyond the circle doesn't undo an arrival`() {
assertTrue(LocationDwell.inside(event = true, distance = 180f, age = 5_000L))
}
}

View file

@ -0,0 +1,41 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
/// Reminders at a place need the position with the app closed (ADR 0033,
/// D32), and their receiver stays the app's own.
void main() {
final manifest = File(
'android/app/src/main/AndroidManifest.xml',
).readAsStringSync();
bool declared(String permission) => manifest.contains(
'<uses-permission android:name="android.permission.$permission"/>',
);
test('the position is declared, precise and in the background', () {
expect(declared('ACCESS_COARSE_LOCATION'), isTrue);
expect(declared('ACCESS_FINE_LOCATION'), isTrue);
expect(declared('ACCESS_BACKGROUND_LOCATION'), isTrue);
});
test('no other app can tell the receiver a place was crossed', () {
expect(
manifest,
contains(
'<receiver android:exported="false" '
'android:name=".LocationReminderReceiver">',
),
);
});
test('a debug build installs beside the release, under its own name', () {
final gradle = File('android/app/build.gradle.kts').readAsStringSync();
expect(gradle, contains('applicationIdSuffix = ".debug"'));
expect(gradle, contains('manifestPlaceholders["appLabel"] = "Todo Debug"'));
expect(
gradle,
contains('manifestPlaceholders["appLabel"] = "Halkyon Todo"'),
);
});
}