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.
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
manifestPlaceholders["appLabel"] = "Halkyon Todo"
|
||||
}
|
||||
|
||||
// The app's own key signs release builds when tool/release.dart hands it over
|
||||
|
|
@ -50,6 +51,14 @@ android {
|
|||
}
|
||||
|
||||
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 {
|
||||
signingConfig =
|
||||
signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
|
||||
|
|
@ -85,4 +94,5 @@ flutter {
|
|||
|
||||
dependencies {
|
||||
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
|
||||
below. -->
|
||||
<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
|
||||
Train's ADR 0030, D53): the reminders scheduled, with their tasks'
|
||||
titles, are kept unsealed. Through Android 11 allowBackup and
|
||||
fullBackupContent stop both, from 12 on the extraction rules stop the
|
||||
transfer. -->
|
||||
<application
|
||||
android:label="Halkyon Todo"
|
||||
android:label="${appLabel}"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
|
|
@ -81,6 +89,15 @@
|
|||
<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
|
||||
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.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<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.
|
||||
ScreenGuard.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