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:
parent
0a9facc3a2
commit
a7385e2169
17 changed files with 499 additions and 125 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue