feat(todo): places are watched by a quiet service, and asked to notify (ADR 0033, D32)

The first walk showed two faults. Android gives a proximity alert of an
app in the background a fix every 30 minutes (17:04:38, then 17:34:43 on
the Pixel), so a short stop goes unseen. And the place did remind, at
17:36:43, but nothing had asked to notify, so Android showed nothing.

A foreground service of the type location now takes its own fixes while
places wait, under the quietest notification there is: as long as
reaching the nearest circle takes at 30 m/s, doubled while the phone
lies still, between 30 s and 5 min. A place with a reminder asks to
notify as any reminder does.

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 18:17:48 +01:00
commit a7385e2169
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 499 additions and 125 deletions

File diff suppressed because one or more lines are too long

View file

@ -29,6 +29,9 @@
<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"/>
<!-- Watching the places waiting, the app closed (ADR 0033, D32). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<!-- No backup and no transfer to another phone (ADR 0033, step 5, as
Train's ADR 0030, D53): the reminders scheduled, with their tasks'
titles, are kept unsealed. Through Android 11 allowBackup and
@ -89,9 +92,13 @@
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
</intent-filter>
</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
<!-- The checks after a place's crossed (ADR 0033, D32), and the watching
started anew after a restart or an update. Sent by the system
through this app's own pending intents, so not exported. -->
<service
android:name=".LocationWatchService"
android:exported="false"
android:foregroundServiceType="location" />
<receiver android:exported="false" android:name=".LocationReminderReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>

View file

@ -2,8 +2,9 @@ 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.
* that driving past one EDEKA on the way to the next doesn't, and how often
* the phone looks where it is. A fix tells when it crosses the circle;
* [CHECK_AFTER_MILLIS] later this decides.
*/
object LocationDwell {
/** The circle around a place. */
@ -29,6 +30,49 @@ object LocationDwell {
event
}
/** A fix rougher than this says nothing of a circle of [RADIUS_METRES]. */
const val USABLE_METRES = 100f
/** The fastest the phone is taken to travel towards a place: 108 km/h. */
private const val FASTEST_METRES_A_SECOND = 30f
/** The soonest next fix, near a circle and moving. */
const val SOONEST_MILLIS = 30_000L
/** The latest next fix, whatever the distance. */
const val LATEST_MILLIS = 300_000L
/** Moved less than this since the last fix, the phone counts as lying still. */
private const val STILL_METRES = 50f
/** Whether a fix of [accuracy] metres can be told from a circle. */
fun usable(accuracy: Float?): Boolean = accuracy != null && accuracy <= USABLE_METRES
/** Whether the phone, [before] in or out (null: not known), is inside, a fix [distance] metres away. */
fun insideAfter(before: Boolean?, distance: Float): Boolean =
distance <= (if (before == true) INSIDE_METRES else RADIUS_METRES)
/** Whether that crossed the circle; a first fix outside crossed nothing. */
fun crossed(before: Boolean?, inside: Boolean): Boolean = (before ?: false) != inside
/** Whether the phone, [moved] metres since the last fix, lies still. */
fun still(moved: Float): Boolean = moved < STILL_METRES
/**
* How long until the next fix: as long as reaching the nearest circle,
* [metres] away, takes at the fastest, doubled for each of [stillFixes]
* fixes the phone lay still, between [SOONEST_MILLIS] and [LATEST_MILLIS].
*/
fun interval(metres: Float?, stillFixes: Int): Long {
val away = ((metres ?: 0f) - RADIUS_METRES).coerceAtLeast(0f)
val reach = (away / FASTEST_METRES_A_SECOND).toLong() * 1000
val base = reach.coerceIn(SOONEST_MILLIS, LATEST_MILLIS)
return (base shl stillFixes.coerceAtMost(MOST_DOUBLINGS)).coerceAtMost(LATEST_MILLIS)
}
/** Enough for [SOONEST_MILLIS] to pass [LATEST_MILLIS]. */
private const val MOST_DOUBLINGS = 4
/** Whether a crossing just seen, [entering] or not, needs a check at all. */
fun wanted(arrive: Boolean, entering: Boolean): Boolean = arrive == entering

View file

@ -3,38 +3,28 @@ 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.
* What the system tells of the places watched (ADR 0033, D32): the check
* after the phone crossed a circle, and a restart or an update, after which
* the watching starts 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)
LocationReminders.log(context, "watching anew: ${intent.action}")
LocationWatchService.sync(context)
}
PROXIMITY, CHECK -> {
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),
)
}
LocationReminders.check(context, watch)
}
}
}
companion object {
const val PROXIMITY = "app.halkyon.todo.LOCATION_PROXIMITY"
const val CHECK = "app.halkyon.todo.LOCATION_CHECK"
const val ID = "id"
}

View file

@ -58,8 +58,8 @@ data class LocationWatch(
}
/**
* 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
* Reminders at a place (ADR 0033, D32): [LocationWatchService]'s fixes, which
* need no Google services and stay 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.
@ -73,9 +73,6 @@ object LocationReminders {
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) {
@ -92,11 +89,17 @@ object LocationReminders {
"replace" -> {
val watches = (call.argument<List<Map<String, Any>>>("watches") ?: emptyList())
.map { LocationWatch.of(JSONObject(it)) }
replace(activity, watches, call.argument<String>("channel").orEmpty())
replace(
activity,
watches,
call.argument<String>("channel").orEmpty(),
call.argument<String>("watching").orEmpty(),
call.argument<String>("watchingChannel").orEmpty(),
)
result.success(null)
}
"access" -> {
registerMissing(activity)
LocationWatchService.sync(activity)
result.success(access(activity))
}
"askWhileInUse" -> ask(
@ -127,7 +130,7 @@ object LocationReminders {
return
}
asking = { asked ->
registerMissing(asked)
LocationWatchService.sync(asked)
result.success(access(asked))
}
activity.requestPermissions(permissions, PERMISSION_REQUEST)
@ -161,94 +164,54 @@ object LocationReminders {
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) {
/** The places still to remind of: what the service watches. */
fun waiting(context: Context): List<LocationWatch> =
watches(context).filter { it.done !in fired(context) }
/** Whether the service may watch: it runs with the app closed. */
fun mayWatch(context: Context): Boolean = access(context) == "always"
/** The words of the service's notification, as the app last gave them. */
fun watchingTitle(context: Context): String =
store(context).getString("watching", null).orEmpty().ifEmpty { "Watching your places" }
fun watchingChannel(context: Context): String =
store(context).getString("watchingChannel", null).orEmpty().ifEmpty { "Places watched" }
/** The plan: what left it stops, and the service watches what is left. */
private fun replace(
context: Context,
watches: List<LocationWatch>,
channel: String,
watching: String,
watchingChannel: 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)
if (watch.id !in ids) stop(context, watch)
}
val dones = watches.map { it.done }.toSet()
store(context).edit()
.putString("watches", JSONArray(watches.map { it.json() }).toString())
.putString("channel", channel)
.putString("watching", watching)
.putString("watchingChannel", watchingChannel)
.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)
}
// changed stays silent.
val waiting = waiting(context)
for (watch in watches - waiting.toSet()) stop(context, watch)
LocationWatchService.sync(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))
private fun stop(context: Context, watch: LocationWatch) {
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()
store(context).edit().remove("inside ${watch.id}").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,
@ -258,8 +221,34 @@ object LocationReminders {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** What a fix at [lat], [lon] means for each place waiting. */
fun seen(context: Context, lat: Double, lon: Double) {
val metres = FloatArray(1)
for (watch in waiting(context)) {
Location.distanceBetween(lat, lon, watch.lat, watch.lon, metres)
val key = "inside ${watch.id}"
val kept = store(context)
val before = if (kept.contains(key)) kept.getBoolean(key, false) else null
val inside = LocationDwell.insideAfter(before, metres[0])
if (LocationDwell.crossed(before, inside)) {
crossed(context, watch, inside)
} else {
kept.edit().putBoolean(key, inside).apply()
}
}
}
/** How far the nearest place waiting is from [lat], [lon]; null without one. */
fun nearest(context: Context, lat: Double, lon: Double): Float? {
val metres = FloatArray(1)
return waiting(context).minOfOrNull {
Location.distanceBetween(lat, lon, it.lat, it.lon, metres)
metres[0]
}
}
/** 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) {
private 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)
@ -297,11 +286,12 @@ object LocationReminders {
/** 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)
if (other.taskId == watch.taskId) stop(context, other)
}
store(context).edit().putStringSet("fired", fired(context) + watch.done).apply()
// Nothing left to watch, the service and its notification go.
LocationWatchService.sync(context)
val notifications = context.getSystemService(NotificationManager::class.java)
if (notifications.getNotificationChannel(NOTIFICATION_CHANNEL) == null) {
notifications.createNotificationChannel(

View file

@ -0,0 +1,181 @@
package app.halkyon.todo
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.location.LocationRequest
import android.os.Build
import android.os.IBinder
import android.os.Looper
import androidx.core.app.ServiceCompat
import kotlin.math.abs
/**
* Watches the places waiting (ADR 0033, D32), while there are any. Android
* gives a proximity alert of an app in the background a fix every 30 minutes,
* which misses a short stop altogether, and its other way of asking in the
* background is held to the same; a foreground service isn't. Its notification
* is the quietest there is, and the fixes come as seldom as a phone far from
* every place, or lying still, can afford ([LocationDwell.interval]).
*/
class LocationWatchService : Service(), LocationListener {
private var requested: Long? = null
private var last: Location? = null
private var stillFixes = 0
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
running = this
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Within seconds of being started, whatever follows.
try {
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification(),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
} else {
0
},
)
} catch (error: RuntimeException) {
// Restarted where Android doesn't allow it, or the permission's gone:
// the app, opened, starts it again.
LocationReminders.log(this, "not in the foreground: $error")
stopSelf()
return START_NOT_STICKY
}
refresh()
return START_STICKY
}
/** Stops where nothing waits, else asks for fixes as the places now are. */
fun refresh() {
if (LocationReminders.waiting(this).isEmpty() || !LocationReminders.mayWatch(this)) {
LocationReminders.log(this, "nothing to watch")
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
return
}
ask(next(last))
}
private fun next(at: Location?): Long =
LocationDwell.interval(at?.let { LocationReminders.nearest(this, it.latitude, it.longitude) }, stillFixes)
/** Asks again only for a real change, as asking starts the wait anew. */
@SuppressLint("MissingPermission") // Started only with the permission; a refusal is caught.
private fun ask(interval: Long) {
val before = requested
if (before != null && abs(before - interval) * 4 < before) return
val manager = getSystemService(LocationManager::class.java)
try {
manager.removeUpdates(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && manager.hasProvider(LocationManager.FUSED_PROVIDER)) {
manager.requestLocationUpdates(
LocationManager.FUSED_PROVIDER,
LocationRequest.Builder(interval).setQuality(LocationRequest.QUALITY_BALANCED_POWER_ACCURACY).build(),
mainExecutor,
this,
)
} else {
manager.requestLocationUpdates(LocationManager.GPS_PROVIDER, interval, 0f, this, Looper.getMainLooper())
}
requested = interval
LocationReminders.log(this, "a fix every ${interval / 1000} s")
} catch (error: SecurityException) {
LocationReminders.log(this, "no fixes: $error")
}
}
override fun onLocationChanged(fix: Location) {
val before = last
// What Android hands over again as it's asked anew says nothing new.
if (before != null && fix.elapsedRealtimeNanos <= before.elapsedRealtimeNanos) return
val accuracy = if (fix.hasAccuracy()) fix.accuracy else null
LocationReminders.log(this, "fix, ${accuracy ?: "?"} m")
if (!LocationDwell.usable(accuracy)) return
stillFixes = if (before != null && LocationDwell.still(before.distanceTo(fix))) stillFixes + 1 else 0
last = fix
LocationReminders.seen(this, fix.latitude, fix.longitude)
ask(next(fix))
}
// Only the phone's location being switched off or on: fixes stop and go on.
override fun onProviderDisabled(provider: String) = Unit
override fun onProviderEnabled(provider: String) = Unit
override fun onDestroy() {
getSystemService(LocationManager::class.java).removeUpdates(this)
running = null
super.onDestroy()
}
private fun notification(): Notification {
val notifications = getSystemService(NotificationManager::class.java)
notifications.createNotificationChannel(
NotificationChannel(
NOTIFICATION_CHANNEL,
LocationReminders.watchingChannel(this),
NotificationManager.IMPORTANCE_MIN,
).apply { setShowBadge(false) },
)
val open = PendingIntent.getActivity(
this,
NOTIFICATION_ID,
Intent(this, MainActivity::class.java).setFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP,
),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, NOTIFICATION_CHANNEL)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(LocationReminders.watchingTitle(this))
.setOngoing(true)
.setContentIntent(open)
.build()
}
companion object {
private const val NOTIFICATION_ID = 4712
private const val NOTIFICATION_CHANNEL = "places_watched"
/** The service of this process, while it runs. */
private var running: LocationWatchService? = null
/**
* Brings the service in line with the plan: told of a change where it
* runs, else started where there is something to watch. Android may
* refuse a start with the app out of sight (a sync in the background):
* the next time the app opens, it starts.
*/
fun sync(context: Context) {
running?.let {
it.refresh()
return
}
if (LocationReminders.waiting(context).isEmpty() || !LocationReminders.mayWatch(context)) return
try {
context.startForegroundService(Intent(context, LocationWatchService::class.java))
} catch (error: IllegalStateException) {
LocationReminders.log(context, "not started: $error")
}
}
}
}

View file

@ -1,5 +1,6 @@
package app.halkyon.todo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@ -52,4 +53,52 @@ class LocationDwellTest {
fun `an inaccurate fix just beyond the circle doesn't undo an arrival`() {
assertTrue(LocationDwell.inside(event = true, distance = 180f, age = 5_000L))
}
@Test
fun `a fix enters inside the circle and leaves only beyond the margin`() {
assertTrue(LocationDwell.insideAfter(before = null, distance = 150f))
assertFalse(LocationDwell.insideAfter(before = null, distance = 151f))
assertFalse(LocationDwell.insideAfter(before = false, distance = 180f))
assertTrue(LocationDwell.insideAfter(before = true, distance = 200f))
assertFalse(LocationDwell.insideAfter(before = true, distance = 201f))
}
@Test
fun `the first fix crosses only by being inside`() {
assertTrue(LocationDwell.crossed(before = null, inside = true))
assertFalse(LocationDwell.crossed(before = null, inside = false))
assertTrue(LocationDwell.crossed(before = false, inside = true))
assertTrue(LocationDwell.crossed(before = true, inside = false))
assertFalse(LocationDwell.crossed(before = true, inside = true))
}
@Test
fun `a fix too rough says nothing of a circle`() {
assertTrue(LocationDwell.usable(accuracy = 100f))
assertFalse(LocationDwell.usable(accuracy = 101f))
assertFalse(LocationDwell.usable(accuracy = null))
}
@Test
fun `the nearer the circle the sooner the next fix`() {
assertEquals(30_000L, LocationDwell.interval(metres = 100f, stillFixes = 0))
assertEquals(30_000L, LocationDwell.interval(metres = 600f, stillFixes = 0))
assertEquals(95_000L, LocationDwell.interval(metres = 3_000f, stillFixes = 0))
assertEquals(300_000L, LocationDwell.interval(metres = 50_000f, stillFixes = 0))
assertEquals(30_000L, LocationDwell.interval(metres = null, stillFixes = 0))
}
@Test
fun `a phone lying still is asked less, up to the most`() {
assertEquals(60_000L, LocationDwell.interval(metres = 100f, stillFixes = 1))
assertEquals(120_000L, LocationDwell.interval(metres = 100f, stillFixes = 2))
assertEquals(300_000L, LocationDwell.interval(metres = 100f, stillFixes = 4))
assertEquals(300_000L, LocationDwell.interval(metres = 100f, stillFixes = 40))
}
@Test
fun `only a move counts as moving`() {
assertTrue(LocationDwell.still(moved = 49f))
assertFalse(LocationDwell.still(moved = 50f))
}
}

View file

@ -292,6 +292,8 @@
"locationRemindArrive": "Beim Ankommen",
"locationRemindLeave": "Beim Verlassen",
"locationRemove": "Entfernen",
"locationWatching": "Achtet auf deine Orte",
"locationWatchingChannel": "Überwachte Orte",
"locationAccessTitle": "Standort immer erlauben",
"locationAccessHint": "Damit Halkyon Todo dich beim Ankommen oder Verlassen erinnert, braucht es deinen Standort auch, wenn es geschlossen ist. Er bleibt auf diesem Gerät. Wähle als Nächstes „Immer zulassen“.",
"locationAccessGo": "Weiter",

View file

@ -988,6 +988,8 @@
"@locationRemove": {
"description": "A task's location's menu."
},
"locationWatching": "Watching your places",
"locationWatchingChannel": "Places watched",
"locationAccessTitle": "Allow location all the time",
"@locationAccessTitle": {
"description": "Before asking for the position in the background."

View file

@ -1852,6 +1852,18 @@ abstract class AppLocalizations {
/// **'Remove'**
String get locationRemove;
/// No description provided for @locationWatching.
///
/// In en, this message translates to:
/// **'Watching your places'**
String get locationWatching;
/// No description provided for @locationWatchingChannel.
///
/// In en, this message translates to:
/// **'Places watched'**
String get locationWatchingChannel;
/// Before asking for the position in the background.
///
/// In en, this message translates to:

View file

@ -1141,6 +1141,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get locationRemove => 'Entfernen';
@override
String get locationWatching => 'Achtet auf deine Orte';
@override
String get locationWatchingChannel => 'Überwachte Orte';
@override
String get locationAccessTitle => 'Standort immer erlauben';

View file

@ -1140,6 +1140,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get locationRemove => 'Remove';
@override
String get locationWatching => 'Watching your places';
@override
String get locationWatchingChannel => 'Places watched';
@override
String get locationAccessTitle => 'Allow location all the time';

View file

@ -12,16 +12,23 @@ enum LocationAccess() {
/// A point on the map.
typedef LatLon = ({double lat, double lon});
/// Where the device watches places: the system's proximity alerts on Android
/// alone, with no Google services, on the device, as the instance reads no
/// task (D32).
/// Where the device watches places: a foreground service on Android alone,
/// with no Google services, on the device, as the instance reads no task
/// (D32).
abstract interface class LocationReminderGateway() {
/// Whether this device can watch places at all.
bool get available;
/// Makes [watches] the places watched, those watched before and no longer
/// among them stopped; [channel] names the notifications' kind.
Future<void> replace(List<LocationWatch> watches, {required String channel});
/// among them stopped; [channel] names the notifications' kind, [watching]
/// says what the quiet notification of watching says, [watchingChannel]
/// names its kind.
Future<void> replace(
List<LocationWatch> watches, {
required String channel,
String watching = '',
String watchingChannel = '',
});
/// What the device allows now.
Future<LocationAccess> access();
@ -49,6 +56,8 @@ class const NoLocationReminders() implements LocationReminderGateway {
Future<void> replace(
List<LocationWatch> watches, {
required String channel,
String watching = '',
String watchingChannel = '',
}) async {}
@override
@ -81,9 +90,13 @@ class const AndroidLocationReminders() implements LocationReminderGateway {
Future<void> replace(
List<LocationWatch> watches, {
required String channel,
String watching = '',
String watchingChannel = '',
}) => _channel.invokeMethod<void>('replace', {
'watches': [for (final watch in watches) watch.toJson()],
'channel': channel,
'watching': watching,
'watchingChannel': watchingChannel,
});
@override

View file

@ -64,15 +64,16 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
final tasks = [
for (final task in state.tasks) StoredTask(task.id, task.content),
];
_watch(tasks);
final watching = _watch(tasks);
final plan = planReminders(tasks, now: clock.now());
// Asked to notify wherever the reminders came from (D31): typed, added
// by the automatic reminder, chosen in a sheet or synced; and read
// again as the app comes back, from the system's settings perhaps.
// by the automatic reminder, chosen in a sheet or synced, or at a place
// (D32); and read again as the app comes back, from the system's
// settings perhaps.
unawaited(
PersonScope.of<TodoPerson>(
context,
).notifications.planned(any: plan.isNotEmpty),
).notifications.planned(any: plan.isNotEmpty || watching),
);
final snooze = state.settings.reminders.snooze;
if (listEquals(plan, _planned) && snooze == _snooze) {
@ -101,8 +102,9 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
);
}
/// Hands the places [tasks] remind at to the device, where they changed.
void _watch(List<StoredTask> tasks) {
/// Hands the places [tasks] remind at to the device, where they changed;
/// whether there are any.
bool _watch(List<StoredTask> tasks) {
final l10n = AppLocalizations.of(context);
final todo = PersonScope.of<TodoPerson>(context);
final watches = [
@ -120,9 +122,12 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
todo.locationReminders.replace(
watches,
channel: l10n.remindersChannel,
watching: l10n.locationWatching,
watchingChannel: l10n.locationWatchingChannel,
),
);
}
return watches.isNotEmpty;
}
@override

View file

@ -2441,6 +2441,48 @@ void main() {
expect(locationGateway.watched, isEmpty);
});
testWidgets('a place with a reminder asks to notify, as any reminder '
'does, and says what the quiet notification says', (tester) async {
reminders.allowed = false;
await start(tester, usedBefore: true);
expect(reminders.asked, 0);
await seed(
tester,
TaskContent(
title: 'Shop',
projectId: inbox(),
locations: const [
TaskLocation(
name: 'EDEKA',
lat: 47.55,
lon: 9.70,
trigger: LocationTrigger.arrive,
),
],
),
);
expect(reminders.asked, 1);
expect(locationGateway.watchingSays, 'Watching your places');
});
testWidgets('a place without a reminder asks nothing', (tester) async {
reminders.allowed = false;
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(
title: 'Shop',
projectId: inbox(),
locations: const [
TaskLocation(name: 'EDEKA', lat: 47.55, lon: 9.70),
],
),
);
expect(reminders.asked, 0);
});
testWidgets('an unchanged plan is not handed over again, nor is a person '
'asked anything for it', (tester) async {
locationGateway.allowed = LocationAccess.whileInUse;

View file

@ -37,6 +37,8 @@ class _Gateway(var LocationAccess now) implements LocationReminderGateway {
Future<void> replace(
List<LocationWatch> watches, {
required String channel,
String watching = '',
String watchingChannel = '',
}) async {}
}

View file

@ -9,6 +9,9 @@ class MemoryLocationGateway() implements LocationReminderGateway {
/// What the device allows.
LocationAccess allowed = LocationAccess.always;
/// What the quiet notification of watching says.
String watchingSays = '';
/// How often the places were handed over.
int replaced = 0;
@ -19,7 +22,10 @@ class MemoryLocationGateway() implements LocationReminderGateway {
Future<void> replace(
List<LocationWatch> watches, {
required String channel,
String watching = '',
String watchingChannel = '',
}) async {
watchingSays = watching;
watched = watches;
replaced++;
}