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:
parent
77d6a5528c
commit
e669c8d5b0
8 changed files with 567 additions and 1 deletions
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue