diff --git a/.claude/skills/watch-gradle-tests/SKILL.md b/.claude/skills/watch-gradle-tests/SKILL.md index 6ee0fbc3..c7c1624f 100644 --- a/.claude/skills/watch-gradle-tests/SKILL.md +++ b/.claude/skills/watch-gradle-tests/SKILL.md @@ -22,6 +22,48 @@ python .claude/skills/watch-gradle-tests/watch_tests.py tmp/run.log Each line it prints is one event: `FAILED .` as each failure appears, `STALLED …` if the log stops growing, and `FINISHED` + `VERDICT` at the end. +### Give every run its own log file + +Reusing one name races the watcher against the run that is starting. Arm a Monitor while the +previous run's log is still on disk and it reads *that* — matches its `BUILD` line, prints its +verdict, and reports a finished run that has not started. The XML age in `VERDICT` is the only +hint, and "2 min old" looks perfectly current. + +That cost a wrong conclusion here: a stale verdict was read as the new run's, its crash trace +pointed at a line number the fix had already moved, and the obvious inference — "the APK did +not rebuild" — was wrong twice over. + +```bash +log=tmp/run-$(date +%H%M%S).log +./gradlew :app:testEmulatorDebugAndroidTest --console=plain > "$log" 2>&1 & +python .claude/skills/watch-gradle-tests/watch_tests.py "$log" +``` + +### Never wrap `--once` in your own sleep loop + +That is the shape step 3 exists to replace, and it looks close enough to right to pass review: + +```bash +# WRONG - and this exact loop cost 1h22m +for i in $(seq 1 110); do + grep -qE "BUILD SUCCESSFUL|BUILD FAILED" tmp/run.log && { ...--once; break; } + sleep 20 +done +``` + +It waits for a **terminal line** and nothing else, so it is blind to the run stopping without +one — which is the failure worth catching. A suite hung 25 minutes on a single test produced no +new output and no verdict, so the loop sat silent, then hit its own limit and exited **0 with no +output at all**: indistinguishable from success. Meanwhile `watch` mode would have said +`STALLED no output for 8 min, at 424/687` seventeen minutes earlier. + +Two rules follow, and they are the same rule twice: + +- **Watch progress, not just completion.** "Still running" and "wedged" look identical unless + something is measuring the gap between outputs. +- **A watcher that can exit silently is not a watcher.** If yours can end without printing, + make the last thing it does print where the run got to. + ## Why this exists Three hand-written monitors in one afternoon each matched **nothing**, and each looked like a diff --git a/.claude/skills/watch-gradle-tests/watch_tests.py b/.claude/skills/watch-gradle-tests/watch_tests.py index c45c7392..38ed2c60 100644 --- a/.claude/skills/watch-gradle-tests/watch_tests.py +++ b/.claude/skills/watch-gradle-tests/watch_tests.py @@ -165,6 +165,15 @@ def diagnose_stall(log: str, age: float) -> list[str]: elif done: lines.append("STALLED tests had been running, so suspect the device rather than the " "lock: adb logcat -b crash") + # **A live process with nothing resumed is the third hang, and it is not a crash.** + # An activity finished itself - a session that would not restore, or a started activity + # that a test had stubbed - and Espresso's root picker then retries on a 30-second + # backoff forever, printing only "No activity currently resumed". The crash buffer is + # empty and the process is alive, so both of the checks above say nothing is wrong. + lines.append("STALLED if the crash buffer is empty, ask what is on screen: " + "adb logcat -d -s TestRunner:I | tail -3 (which test never finished) and " + "adb logcat -d | grep RootViewPicker (an activity finished and nothing " + "replaced it)") return lines diff --git a/.claude/skills/watch-pr/SKILL.md b/.claude/skills/watch-pr/SKILL.md index 81603aaa..5d35bd5f 100644 --- a/.claude/skills/watch-pr/SKILL.md +++ b/.claude/skills/watch-pr/SKILL.md @@ -88,7 +88,7 @@ while true; do break fi if [ "$(gh pr view "$PR" --json statusCheckRollup --jq \ - '[.statusCheckRollup[].status] | all(. == "COMPLETED")' 2>/dev/null)" = "true" ]; then + '[.statusCheckRollup[].status] | length > 0 and all(. == "COMPLETED")' 2>/dev/null)" = "true" ]; then echo "PR #$PR: all $total checks finished" break fi @@ -112,6 +112,22 @@ Why it is shaped this way: through a red build, and silence reads as "still running". - **`2>/dev/null` on the `gh` calls, but no `|| continue`.** A transient API failure yields an empty result and the loop tries again; it must not be able to exit quietly. +- **`length > 0 and` before the `all`, because `all` on an empty list is `true`.** Without it the + loop declares victory the instant the rollup is empty — which is exactly what a freshly pushed + commit looks like before GitHub has created its runs, and what a fork PR waiting on + *"Approve and run"* looks like indefinitely. It printed `all checks finished` for a PR with + **zero** checks, one line after a push, and that reads identically to a green build. + +**A fork PR can sit at `action_required` forever, and that is not a failure state.** GitHub gates +workflow runs on PRs from forks, so `gh run list` shows `completed / action_required` and +`gh pr checks` says *"no checks reported"*. Nothing is wrong and nothing will happen until a +maintainer approves: + +```bash +gh run list --limit 5 --json databaseId,headSha,conclusion \ + --jq '.[] | select(.conclusion=="action_required") | .databaseId' +gh api -X POST repos///actions/runs//approve +``` ## When it lands diff --git a/.gitignore b/.gitignore index 45fdaa9c..ae46c51b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ tmp/ # stripped: device names ("'s iPad"), record UUIDs, key material. Committing it would # publish, in a notebook, exactly what was redacted out of the plists next to it. scripts/plist_explorer.ipynb + +# Local throwaway signing key for release builds. Never a real one. +*.jks diff --git a/AGENTS.md b/AGENTS.md index 760f06af..382d16bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,18 @@ through [FindMy.py](https://github.com/malmeloo/FindMy.py). `app/src/main/python/` is packaged into the APK by Chaquopy. Nothing in it may import Android or Java types — that is what makes it testable on plain CPython. +### It ships as an APK, and there is no Play Store listing + +There is no listing and none is planned. Releases are GitHub releases; people sideload them. + +**So "Play Store policy requires it" is never a reason for anything here**, and an argument +leaning on it has nothing behind it. That cuts in a direction people find surprising: the +manifest's declarations, the permission flags and the data-safety claims in comments still have +to be *true* — because Android acts on them and because people read this source — not because a +reviewer will check. Nobody is going to check. That is the point. + +Do not build for a listing that does not exist. + --- ## Rules diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..9610b0e3 --- /dev/null +++ b/NOTICE @@ -0,0 +1,48 @@ +OpenTagViewer +Copyright (c) parawanderer and contributors + +This product is licensed under the MIT License (see LICENSE.TXT), and includes +work derived from third-party software under other licences, listed below. + +-------------------------------------------------------------------------------- + +AirGuard (https://github.com/seemoo-lab/AirGuard) +by the Secure Mobile Networking Lab (SEEMOO), TU Darmstadt +Licensed under the Apache License, Version 2.0 + +(AirGuard's LICENSE is the unmodified Apache-2.0 text and declares no +copyright line of its own; this attributes the project rather than restating +a notice its authors did not write.) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +What is derived, and where it lives: + + app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java + + The Bluetooth LE "play sound" protocol details for Find My / DULT + accessories: the service and characteristic UUIDs, the start opcodes, and + the order the three protocols (DULT, the generic Find My "fd44" service, + and the AirTag-specific service) are tried in, along with the + enable-notifications-then-write sequence each one needs. + + Taken from AirGuard's + app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/AppleFindMy.kt + and cross-checked against + app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/GoogleFindMyNetwork.kt + + The surrounding implementation - the RxJava3 state machine, the scan, + the retry policy and the app's own UI - is not derived from AirGuard. + + Apache-2.0 terms continue to apply to the derived portion above. AirGuard + ships no NOTICE file of its own, so there is none to propagate here. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index afd1dff8..baeb9eb9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -104,7 +104,10 @@ android { // field exists in both variants so code reading it compiles in both. buildConfigField("String", "BUILD_COMMIT", "null") - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // Grants the app's runtime permissions before the first test - see the class, which + // explains why an ungranted one hangs the suite rather than failing a test. + testInstrumentationRunner = + "dev.wander.android.opentagviewer.GrantWhatTheAppAsksForRunner" // **Do not add `timeout_msec` here.** It works - a hanging test fails at the cap with // its own name - but AndroidJUnitRunner pays for it per test, not per hang: with it set @@ -569,13 +572,19 @@ dependencies { * talk to a device - so the caching this disables was never doing anything. Gradle's own error * suggests exactly this. * - * The managed-device task is untouched, because `managedDevice/` is not the directory Studio - * watches. + * **This used to exempt the managed-device task**, on the reasoning that `managedDevice/` is not + * the directory Studio watches. That was wrong: `testEmulatorDebugAndroidTest` failed with the + * identical `Failed to create MD5 hash for file content`, seven seconds in, having run nothing. + * Both tasks write results Studio may be holding, and neither can ever be up to date, so both + * are untracked now. */ -tasks.matching { it.name.startsWith("connected") && it.name.endsWith("AndroidTest") } +tasks.matching { + (it.name.startsWith("connected") || it.name.startsWith("testEmulator")) + && it.name.endsWith("AndroidTest") +} .configureEach { doNotTrackState( - "Android Studio holds the connected results directory open on Windows, and an" + + "Android Studio holds the instrumented results directories open on Windows, and an" + " instrumented run is never up to date regardless.", ) } diff --git a/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/7.json b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/7.json new file mode 100644 index 00000000..d1814392 --- /dev/null +++ b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/7.json @@ -0,0 +1,498 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "8aceaf4e5a85415cea681b4344a1d402", + "entities": [ + { + "tableName": "Import", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `version` TEXT, `imported_at` INTEGER NOT NULL, `exported_at` INTEGER NOT NULL, `source_user` TEXT, `via` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "importedAt", + "columnName": "imported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exportedAt", + "columnName": "exported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceUser", + "columnName": "source_user", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "exportedVia", + "columnName": "via", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BeaconNamingRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `version` TEXT, `content` TEXT, `is_removed` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BeaconNamingRecord_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BeaconNamingRecord_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "OwnedBeacons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `content` TEXT, `version` TEXT, `is_removed` INTEGER NOT NULL, `from_account` INTEGER NOT NULL, `fruitless_scans` INTEGER NOT NULL DEFAULT 0, `last_scan_at` INTEGER, `ignored_at` INTEGER, `accessory_json` TEXT, `alignment_plist` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fromAccount", + "columnName": "from_account", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fruitlessScans", + "columnName": "fruitless_scans", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastScanAt", + "columnName": "last_scan_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignored_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accessoryJson", + "columnName": "accessory_json", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "alignmentPlist", + "columnName": "alignment_plist", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_OwnedBeacons_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_OwnedBeacons_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LocationReport", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hash_id` TEXT NOT NULL, `beacon_id` TEXT NOT NULL, `published_at` INTEGER NOT NULL, `description` TEXT, `timestamp` INTEGER NOT NULL, `confidence` INTEGER NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `horizontal_accuracy` INTEGER NOT NULL, `status` INTEGER NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`hash_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "hashId", + "columnName": "hash_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publishedAt", + "columnName": "published_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confidence", + "columnName": "confidence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "horizontalAccuracy", + "columnName": "horizontal_accuracy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "hash_id" + ] + }, + "indices": [ + { + "name": "index_LocationReport_hash_id_beacon_id_timestamp", + "unique": false, + "columnNames": [ + "hash_id", + "beacon_id", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocationReport_hash_id_beacon_id_timestamp` ON `${TABLE_NAME}` (`hash_id`, `beacon_id`, `timestamp`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DailyHistoryFetchRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_start_time` INTEGER NOT NULL, `beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`day_start_time`, `beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "dayStartTime", + "columnName": "day_start_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day_start_time", + "beacon_id" + ] + }, + "indices": [ + { + "name": "index_DailyHistoryFetchRecord_beacon_id", + "unique": false, + "columnNames": [ + "beacon_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DailyHistoryFetchRecord_beacon_id` ON `${TABLE_NAME}` (`beacon_id`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UserBeaconOptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, `ui_name` TEXT, `ui_emoji` TEXT, `ui_order` INTEGER, PRIMARY KEY(`beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uiName", + "columnName": "ui_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiEmoji", + "columnName": "ui_emoji", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiOrder", + "columnName": "ui_order", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "beacon_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LastBleSighting", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beacon_id` TEXT NOT NULL, `heard_at` INTEGER NOT NULL, `battery_level` TEXT NOT NULL, `status_byte` INTEGER NOT NULL, PRIMARY KEY(`beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "heardAt", + "columnName": "heard_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "batteryLevel", + "columnName": "battery_level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusByte", + "columnName": "status_byte", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "beacon_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8aceaf4e5a85415cea681b4344a1d402')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/8.json b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/8.json new file mode 100644 index 00000000..120c8031 --- /dev/null +++ b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/8.json @@ -0,0 +1,505 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "fe546a06e22198add195b7760075cd65", + "entities": [ + { + "tableName": "Import", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `version` TEXT, `imported_at` INTEGER NOT NULL, `exported_at` INTEGER NOT NULL, `source_user` TEXT, `via` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "importedAt", + "columnName": "imported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exportedAt", + "columnName": "exported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceUser", + "columnName": "source_user", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "exportedVia", + "columnName": "via", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BeaconNamingRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `version` TEXT, `content` TEXT, `is_removed` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BeaconNamingRecord_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BeaconNamingRecord_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "OwnedBeacons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `content` TEXT, `version` TEXT, `is_removed` INTEGER NOT NULL, `from_account` INTEGER NOT NULL, `fruitless_scans` INTEGER NOT NULL DEFAULT 0, `last_scan_at` INTEGER, `ignored_at` INTEGER, `accessory_json` TEXT, `alignment_plist` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fromAccount", + "columnName": "from_account", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fruitlessScans", + "columnName": "fruitless_scans", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastScanAt", + "columnName": "last_scan_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignored_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accessoryJson", + "columnName": "accessory_json", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "alignmentPlist", + "columnName": "alignment_plist", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_OwnedBeacons_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_OwnedBeacons_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LocationReport", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hash_id` TEXT NOT NULL, `beacon_id` TEXT NOT NULL, `published_at` INTEGER NOT NULL, `description` TEXT, `timestamp` INTEGER NOT NULL, `confidence` INTEGER NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `horizontal_accuracy` INTEGER NOT NULL, `status` INTEGER NOT NULL, `last_update` INTEGER NOT NULL, `provenance` TEXT NOT NULL DEFAULT 'apple', PRIMARY KEY(`hash_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "hashId", + "columnName": "hash_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publishedAt", + "columnName": "published_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confidence", + "columnName": "confidence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "horizontalAccuracy", + "columnName": "horizontal_accuracy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "provenance", + "columnName": "provenance", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'apple'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "hash_id" + ] + }, + "indices": [ + { + "name": "index_LocationReport_hash_id_beacon_id_timestamp", + "unique": false, + "columnNames": [ + "hash_id", + "beacon_id", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocationReport_hash_id_beacon_id_timestamp` ON `${TABLE_NAME}` (`hash_id`, `beacon_id`, `timestamp`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DailyHistoryFetchRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_start_time` INTEGER NOT NULL, `beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`day_start_time`, `beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "dayStartTime", + "columnName": "day_start_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day_start_time", + "beacon_id" + ] + }, + "indices": [ + { + "name": "index_DailyHistoryFetchRecord_beacon_id", + "unique": false, + "columnNames": [ + "beacon_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DailyHistoryFetchRecord_beacon_id` ON `${TABLE_NAME}` (`beacon_id`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UserBeaconOptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, `ui_name` TEXT, `ui_emoji` TEXT, `ui_order` INTEGER, PRIMARY KEY(`beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uiName", + "columnName": "ui_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiEmoji", + "columnName": "ui_emoji", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiOrder", + "columnName": "ui_order", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "beacon_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LastBleSighting", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beacon_id` TEXT NOT NULL, `heard_at` INTEGER NOT NULL, `battery_level` TEXT NOT NULL, `status_byte` INTEGER NOT NULL, PRIMARY KEY(`beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "heardAt", + "columnName": "heard_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "batteryLevel", + "columnName": "battery_level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusByte", + "columnName": "status_byte", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "beacon_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fe546a06e22198add195b7760075cd65')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/9.json b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/9.json new file mode 100644 index 00000000..05c246ba --- /dev/null +++ b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/9.json @@ -0,0 +1,511 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "2baa276e969882045c005d4144417dbe", + "entities": [ + { + "tableName": "Import", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `version` TEXT, `imported_at` INTEGER NOT NULL, `exported_at` INTEGER NOT NULL, `source_user` TEXT, `via` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "importedAt", + "columnName": "imported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exportedAt", + "columnName": "exported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceUser", + "columnName": "source_user", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "exportedVia", + "columnName": "via", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BeaconNamingRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `version` TEXT, `content` TEXT, `is_removed` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BeaconNamingRecord_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BeaconNamingRecord_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "OwnedBeacons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `content` TEXT, `version` TEXT, `is_removed` INTEGER NOT NULL, `from_account` INTEGER NOT NULL, `fruitless_scans` INTEGER NOT NULL DEFAULT 0, `last_scan_at` INTEGER, `ignored_at` INTEGER, `accessory_json` TEXT, `alignment_plist` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fromAccount", + "columnName": "from_account", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fruitlessScans", + "columnName": "fruitless_scans", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastScanAt", + "columnName": "last_scan_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignored_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "accessoryJson", + "columnName": "accessory_json", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "alignmentPlist", + "columnName": "alignment_plist", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_OwnedBeacons_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_OwnedBeacons_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LocationReport", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hash_id` TEXT NOT NULL, `beacon_id` TEXT NOT NULL, `published_at` INTEGER NOT NULL, `description` TEXT, `timestamp` INTEGER NOT NULL, `confidence` INTEGER NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `horizontal_accuracy` INTEGER NOT NULL, `status` INTEGER NOT NULL, `last_update` INTEGER NOT NULL, `provenance` TEXT NOT NULL DEFAULT 'apple', PRIMARY KEY(`hash_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "hashId", + "columnName": "hash_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publishedAt", + "columnName": "published_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confidence", + "columnName": "confidence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "horizontalAccuracy", + "columnName": "horizontal_accuracy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "provenance", + "columnName": "provenance", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'apple'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "hash_id" + ] + }, + "indices": [ + { + "name": "index_LocationReport_hash_id_beacon_id_timestamp", + "unique": false, + "columnNames": [ + "hash_id", + "beacon_id", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocationReport_hash_id_beacon_id_timestamp` ON `${TABLE_NAME}` (`hash_id`, `beacon_id`, `timestamp`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DailyHistoryFetchRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_start_time` INTEGER NOT NULL, `beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`day_start_time`, `beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "dayStartTime", + "columnName": "day_start_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day_start_time", + "beacon_id" + ] + }, + "indices": [ + { + "name": "index_DailyHistoryFetchRecord_beacon_id", + "unique": false, + "columnNames": [ + "beacon_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DailyHistoryFetchRecord_beacon_id` ON `${TABLE_NAME}` (`beacon_id`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UserBeaconOptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, `ui_name` TEXT, `ui_emoji` TEXT, `ui_order` INTEGER, `alert_on_separation` INTEGER, PRIMARY KEY(`beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uiName", + "columnName": "ui_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiEmoji", + "columnName": "ui_emoji", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiOrder", + "columnName": "ui_order", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "alertOnSeparation", + "columnName": "alert_on_separation", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "beacon_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LastBleSighting", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beacon_id` TEXT NOT NULL, `heard_at` INTEGER NOT NULL, `battery_level` TEXT NOT NULL, `status_byte` INTEGER NOT NULL, PRIMARY KEY(`beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "heardAt", + "columnName": "heard_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "batteryLevel", + "columnName": "battery_level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusByte", + "columnName": "status_byte", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "beacon_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2baa276e969882045c005d4144417dbe')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java index 48c1f945..9be60d1c 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java @@ -38,6 +38,7 @@ import dev.wander.android.opentagviewer.anisette.AdiDeviceIdentity; import dev.wander.android.opentagviewer.db.AccountBeaconsForTests; import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.ui.compat.TheNavigationBar; import dev.wander.android.opentagviewer.python.icloud.FakeICloudService; import dev.wander.android.opentagviewer.python.icloud.ICloudService; @@ -123,6 +124,27 @@ private boolean isShown(final int id) { return shown[0]; } + /** + * The Unlock button is not under the navigation bar. + * + *

This is the screen from @parawanderer's report - a screenshot of the passcode step with + * Unlock behind the gesture pill, on a phone whose bar is tall. Every screen is checked for + * this in {@code NothingSitsUnderTheNavigationBarTest}; this one is checked here instead, + * because it closes itself in {@code onCreate} without a usable iCloud session and this class + * is what knows how to give it one. Rebuilding that setup there would be the copy that drifts. + * + *

The bar is invented rather than measured - see {@link TheNavigationBar} for why that is + * the only way to ask this on a device whose real inset is zero. + */ + @Test + public void theunlockButtonStaysClearOfTheNavigationBar() { + this.open(FakeICloudService.withTags()); + + this.chooseTheFirstDevice(); + + TheNavigationBar.doesNotCover(this.scenario, "FetchFromICloudActivity"); + } + /** The whole errand: choose a device, unlock, see what is on the account. */ @Test public void thewholeFlowReachesTheTagsOnTheAccount() { diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/GrantWhatTheAppAsksForRunner.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/GrantWhatTheAppAsksForRunner.java new file mode 100644 index 00000000..f9a75d89 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/GrantWhatTheAppAsksForRunner.java @@ -0,0 +1,63 @@ +package dev.wander.android.opentagviewer; + +import android.Manifest; + +import androidx.test.runner.AndroidJUnitRunner; + +import java.util.ArrayList; +import java.util.List; + +import dev.wander.android.opentagviewer.ble.BlePermissions; + +/** + * Grants the runtime permissions the app asks for on startup, before any test runs. + * + *

An ungranted permission does not fail a test here - it hangs the suite. The screens + * ask for what they need the moment they open, and a system permission dialog belongs to the + * permission controller rather than to this app: it takes focus, pauses the activity, and leaves + * Espresso with nothing resumed to look at. Its root picker then retries on a thirty-second + * backoff, and since {@code timeout_msec} is deliberately not set (it cost about three minutes a + * run), nothing ever stops it. The test does not fail; it never ends, and whatever limit is + * outermost - 45 minutes on CI - is what finally kills the job. + * + *

Per-class rules could not keep up, which is the actual reason this exists. The map + * fixture granted location up front and said in its own javadoc that a test which forgot the + * rule would hit the same wall. Then the nearby-tags work made the map ask for Bluetooth as it + * opens, and eight classes reach the map without that fixture - one of them with a + * {@code GrantPermissionRule} listing exactly the permissions that used to be enough. Nothing + * was wrong with any of them. They were written before the app asked for one more thing, and a + * rule is per-class, so there is no single place that a new permission can be added. + * + *

This is that place. Granting before the first test costs nothing and removes the whole + * class of failure. + * + *

Safe because nothing here tests a refusal. No test in this source set asserts that a + * permission is requested, rationalised or denied - checked before writing this - so there is no + * behaviour for a blanket grant to hide. If one is ever added, it needs to revoke what it is + * about in its own setup, and this comment is the warning that it must. + */ +public final class GrantWhatTheAppAsksForRunner extends AndroidJUnitRunner { + + @Override + public void onStart() { + final List needed = new ArrayList<>(List.of( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION)); + + // Read from the app rather than named again: which permissions Bluetooth needs depends + // on the API level, and a second copy is one that can be wrong without anybody noticing. + needed.addAll(List.of(BlePermissions.required())); + + final String packageName = this.getTargetContext().getPackageName(); + for (final String permission : needed) { + try { + this.getUiAutomation().grantRuntimePermission(packageName, permission); + } catch (final RuntimeException alreadyHeldOrNotGrantable) { + // Already granted, or not a runtime permission on this API level. Both are fine: + // the point is only that no dialog appears once the tests start. + } + } + + super.onStart(); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/AccountRefreshKeepsWhatTheUserOwnsTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/AccountRefreshKeepsWhatTheUserOwnsTest.java index 3fe676a3..65d0b016 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/AccountRefreshKeepsWhatTheUserOwnsTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/AccountRefreshKeepsWhatTheUserOwnsTest.java @@ -99,6 +99,7 @@ private void givenItHasSomeHistory() { .horizontalAccuracy(83) .status(144) .lastUpdate(1_000L) + .provenance(LocationReport.PROVENANCE_APPLE) .build()); } diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/KeepingWhatATagLastSaidTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/KeepingWhatATagLastSaidTest.java new file mode 100644 index 00000000..c5db766a --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/KeepingWhatATagLastSaidTest.java @@ -0,0 +1,194 @@ +package dev.wander.android.opentagviewer.db.repo; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import androidx.room.Room; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Optional; + +import dev.wander.android.opentagviewer.ble.FindMyAdvertisement.BatteryLevel; +import dev.wander.android.opentagviewer.db.repo.model.LastSightingData; +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.db.room.entity.LastBleSighting; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; + +/** + * What a tag said over the air, kept after the tag itself has gone quiet. + * + *

Why it is stored at all. The accessory record's own battery field is written by + * Apple's devices as they walk past the tag, so for somebody with no Apple device it reads 0, + * "not yet reported", forever - which is what both of the real tags this was built against still + * report. The advertisement is then the only source there is, and it is only audible while the + * tag is in range. Not keeping it would mean the one battery reading these users can get + * disappears thirty seconds after it arrives. + * + *

The most recent sighting only. Every advertisement carries the same two bits, so a + * history of them would be thousands of rows saying "full" to answer a question that only ever + * needs the last one. + */ +@RunWith(AndroidJUnit4.class) +public class KeepingWhatATagLastSaidTest { + + private static final String A_TAG = "a-tag"; + private static final String ANOTHER_TAG = "another-tag"; + private static final String A_PLIST = ""; + + /** Status bytes whose top two bits read as each level, as a real one would. */ + private static final int FULL_BYTE = 0b0000_0000; + private static final int MEDIUM_BYTE = 0b0100_0000; + private static final int LOW_BYTE = 0b1000_0000; + private static final int VERY_LOW_BYTE = 0b1100_0000; + + private static final long MORNING = 1_700_000_000_000L; + private static final long AFTERNOON = MORNING + 21_600_000L; + + private OpenTagViewerDatabase db; + private BeaconRepository repo; + + @Before + public void openAnInMemoryDatabase() { + this.db = Room.inMemoryDatabaseBuilder( + getInstrumentation().getTargetContext(), OpenTagViewerDatabase.class) + .allowMainThreadQueries() + .build(); + + this.repo = new BeaconRepository(this.db, (plist, alignment) -> "{\"type\":\"accessory\"}"); + + this.insertTag(A_TAG); + this.insertTag(ANOTHER_TAG); + } + + @After + public void closeIt() { + this.db.close(); + } + + private void insertTag(final String id) { + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(id).content(A_PLIST).accessoryJson("{\"type\":\"accessory\"}") + .version("0.0.2").fromAccount(false).isRemoved(false).build()); + } + + private Optional readBack(final String beaconId) { + return this.repo.getLastSighting(beaconId).blockingFirst(); + } + + /** A tag nothing has ever heard has no sighting, and must not be given one. */ + @Test + public void aTagNeverHeardHasNothingStored() { + assertTrue("a tag that has never been heard must not report a battery level", + this.readBack(A_TAG).isEmpty()); + } + + @Test + public void whatTheTagSaidSurvivesTheSightingThatCarriedIt() { + this.repo.storeLastSighting(A_TAG, BatteryLevel.MEDIUM, MEDIUM_BYTE, MORNING) + .blockingAwait(); + + final Optional stored = this.readBack(A_TAG); + + assertTrue(stored.isPresent()); + assertEquals(BatteryLevel.MEDIUM, stored.get().getBatteryLevel()); + assertEquals("a sighting must carry the moment it was heard, or nothing on it can be" + + " shown with its age", MORNING, stored.get().getHeardAtMs()); + assertEquals("the raw byte is what a disputed reading gets re-derived from", + MEDIUM_BYTE, stored.get().getStatusByte()); + } + + /** + * A later sighting replaces the earlier one rather than joining it. The point of the row is + * "what it last said", and a tag draining from full to low must not still be able to answer + * "full". + */ + @Test + public void aFresherSightingReplacesTheOneBeforeIt() { + this.repo.storeLastSighting(A_TAG, BatteryLevel.FULL, FULL_BYTE, MORNING).blockingAwait(); + this.repo.storeLastSighting(A_TAG, BatteryLevel.LOW, LOW_BYTE, AFTERNOON).blockingAwait(); + + final Optional stored = this.readBack(A_TAG); + + assertTrue(stored.isPresent()); + assertEquals(BatteryLevel.LOW, stored.get().getBatteryLevel()); + assertEquals(AFTERNOON, stored.get().getHeardAtMs()); + + try (var cursor = this.db.query( + "SELECT COUNT(*) FROM LastBleSighting WHERE beacon_id = ?", + new Object[]{A_TAG})) { + assertTrue(cursor.moveToFirst()); + assertEquals("the table holds the latest sighting per tag, not a history of them", + 1, cursor.getInt(0)); + } + } + + /** One tag's sighting is not another's, which a single-row-per-tag table has to get right. */ + @Test + public void eachTagKeepsItsOwn() { + this.repo.storeLastSighting(A_TAG, BatteryLevel.FULL, FULL_BYTE, MORNING).blockingAwait(); + this.repo.storeLastSighting(ANOTHER_TAG, BatteryLevel.VERY_LOW, VERY_LOW_BYTE, MORNING) + .blockingAwait(); + + assertEquals(BatteryLevel.FULL, this.readBack(A_TAG).get().getBatteryLevel()); + assertEquals(BatteryLevel.VERY_LOW, this.readBack(ANOTHER_TAG).get().getBatteryLevel()); + } + + /** + * A battery level this build does not know makes the whole sighting unreadable rather than a + * guess. + * + *

The case is a row written by a later version that understands a state this one does not, + * met after a downgrade or a shared database. Every available way to map it onto the four + * states here produces a wrong reading shown as a right one, so the row is passed over. It + * stays in the table, raw byte and all, for whoever is debugging it. + */ + @Test + public void anUnknownStoredLevelIsNoReadingRatherThanAGuess() { + this.db.lastBleSightingDao().insert(LastBleSighting.builder() + .beaconId(A_TAG) + .heardAt(MORNING) + .batteryLevel("HALF_ISH") + .statusByte(MEDIUM_BYTE) + .build()); + + assertTrue("an unrecognised level must not be rounded to a neighbouring one", + this.readBack(A_TAG).isEmpty()); + + try (var cursor = this.db.query( + "SELECT battery_level FROM LastBleSighting WHERE beacon_id = ?", + new Object[]{A_TAG})) { + assertTrue(cursor.moveToFirst()); + assertEquals("the unreadable row must be left alone, not deleted", + "HALF_ISH", cursor.getString(0)); + } + } + + /** + * Removing a tag takes its stored sighting with it. + * + *

The foreign key is what makes that automatic. Without it a sighting would outlive the tag + * it describes and be waiting to be shown against a re-imported tag of the same id, dated + * before that tag was ever added here. + */ + @Test + public void deletingATagTakesItsSightingWithIt() { + this.repo.storeLastSighting(A_TAG, BatteryLevel.FULL, FULL_BYTE, MORNING).blockingAwait(); + this.repo.storeLastSighting(ANOTHER_TAG, BatteryLevel.LOW, LOW_BYTE, MORNING) + .blockingAwait(); + + this.db.getOpenHelper().getWritableDatabase() + .execSQL("DELETE FROM OwnedBeacons WHERE id = ?", new Object[]{A_TAG}); + + assertTrue("a deleted tag must not leave a sighting behind", + this.readBack(A_TAG).isEmpty()); + assertFalse("deleting one tag must not touch another's sighting", + this.readBack(ANOTHER_TAG).isEmpty()); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/WhichFetchesAreWorthABannerTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/WhichFetchesAreWorthABannerTest.java new file mode 100644 index 00000000..262a4c83 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/WhichFetchesAreWorthABannerTest.java @@ -0,0 +1,167 @@ +package dev.wander.android.opentagviewer.db.repo; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import androidx.room.Room; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.AccessoryRequest; + +/** + * Which fetches put the "still locating your tags" banner up. + * + *

The bug, from using the app: the banner appeared during loads that finished + * immediately. It went up for any fetch still running after six seconds, which on a slow + * network is most of them - and a warning that shows when nothing is wrong is one people stop + * reading before the day it matters. + * + *

What makes a fetch genuinely long is the key search, and how far back that starts is + * decided by the accessory's {@code KeyAlignmentRecord} - so it is knowable before a single + * request goes out. {@code SlowFirstFetchTest} covers the arithmetic on the JVM; this covers + * the part that needs a database, which is reading the record off the stored beacon at all. + * + *

Both halves are needed. The predicate is right and useless if the plist never parses, and + * a wrong XPath here would silently answer "no alignment, so slow" for every tag - restoring + * exactly the behaviour being fixed, with the JVM tests still green. + */ +@RunWith(AndroidJUnit4.class) +public class WhichFetchesAreWorthABannerTest { + + private static final String A_PLIST = ""; + + private OpenTagViewerDatabase db; + private BeaconRepository repo; + + @Before + public void openAnInMemoryDatabase() { + this.db = Room.inMemoryDatabaseBuilder( + getInstrumentation().getTargetContext(), OpenTagViewerDatabase.class) + .allowMainThreadQueries() + .build(); + + this.repo = new BeaconRepository(this.db, (plist, alignment) -> "{\"type\":\"accessory\"}"); + } + + @After + public void closeIt() { + this.db.close(); + } + + /** The shape the exporter writes: a key, then its typed sibling. */ + private static String alignedAt(final Instant when) { + return "" + + "lastIndexObservationDate" + + "" + when.toString() + "" + + ""; + } + + private void givenABeacon(final String id, final String alignmentPlist) { + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(id) + .content(A_PLIST) + .alignmentPlist(alignmentPlist) + .version("0.0.2") + .fromAccount(false) + .isRemoved(false) + .build()); + } + + private boolean wouldBeSlow(final String... beaconIds) { + final List requests = new java.util.ArrayList<>(); + for (final String id : beaconIds) { + requests.add(new AccessoryRequest(id, "{\"type\":\"accessory\"}")); + } + return this.repo.aFetchOfTheseWouldBeSlow(requests).blockingFirst(); + } + + // ------------------------------------------------------------------ quick, so stay quiet + + @Test + public void aTagAlignedThisMorningNeedsNoBanner() { + this.givenABeacon("recent", alignedAt(Instant.now().minus(6, ChronoUnit.HOURS))); + + assertFalse("a few hours of keys is one request", this.wouldBeSlow("recent")); + } + + /** + * The regression this is really for. If the plist stops parsing - a changed XPath, a + * date format nobody anticipated - every tag reads as unaligned and the banner comes back + * for everything, which is the behaviour being fixed. Only a real record through the real + * reader catches that. + */ + @Test + public void aStoredAlignmentRecordIsActuallyRead() { + this.givenABeacon("aligned", alignedAt(Instant.now().minus(2, ChronoUnit.DAYS))); + + assertFalse("the alignment record was stored but not read, so this tag looked unaligned", + this.wouldBeSlow("aligned")); + } + + @Test + public void aWholeBatchOfRecentlyAlignedTagsNeedsNoBanner() { + this.givenABeacon("a", alignedAt(Instant.now().minus(1, ChronoUnit.DAYS))); + this.givenABeacon("b", alignedAt(Instant.now().minus(2, ChronoUnit.DAYS))); + this.givenABeacon("c", alignedAt(Instant.now().minus(3, ChronoUnit.DAYS))); + + assertFalse("three quick tags is still a quick fetch", this.wouldBeSlow("a", "b", "c")); + } + + // ------------------------------------------------------------------ slow, so say so + + @Test + public void aTagWithNoAlignmentRecordIsWorthABanner() { + this.givenABeacon("never-aligned", null); + + assertTrue("with no record it searches from the pairing date", + this.wouldBeSlow("never-aligned")); + } + + @Test + public void aTagAlignedMonthsAgoIsWorthABanner() { + this.givenABeacon("stale", alignedAt(Instant.now().minus(90, ChronoUnit.DAYS))); + + assertTrue("three months is roughly 8,600 keys", this.wouldBeSlow("stale")); + } + + @Test + public void oneUnalignedTagAmongQuickOnesStillWarrantsIt() { + this.givenABeacon("quick", alignedAt(Instant.now().minus(1, ChronoUnit.DAYS))); + this.givenABeacon("unaligned", null); + + assertTrue("the batch is fetched one at a time, so the slow one holds up the rest", + this.wouldBeSlow("quick", "unaligned")); + } + + /** + * A tag being fetched that this app has no row for - a self-generated one, or a request + * built from a fallback plist. Unknown, so warn rather than stay silent. + */ + @Test + public void aTagWithNoStoredRowAtAllWarnsRatherThanStaysSilent() { + assertTrue("nothing is known about it, and silence is the failure being avoided", + this.wouldBeSlow("never-heard-of-it")); + } + + /** Damaged rather than absent: unreadable is the same answer as unknown, not a crash. */ + @Test + public void anUnreadableAlignmentRecordIsTreatedAsUnaligned() { + this.givenABeacon("damaged", "lastIndex" + + "ObservationDatenot a date at all"); + + assertTrue("an unreadable record tells us nothing about where the search starts", + this.wouldBeSlow("damaged")); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/WritingDownWhereATagWasHeardTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/WritingDownWhereATagWasHeardTest.java new file mode 100644 index 00000000..59b53dee --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/WritingDownWhereATagWasHeardTest.java @@ -0,0 +1,202 @@ +package dev.wander.android.opentagviewer.db.repo; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import androidx.room.Room; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.List; + +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.db.room.entity.LocationReport; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.util.LocalFixWorthKeeping; + +/** + * A position this phone worked out for itself, stored beside the ones Apple's network sent. + * + *

Same table, different claim. Everything that draws a tag reads from + * {@code LocationReport}, so a locally heard position has to land there to be of any use - but a + * row saying "a stranger's iPhone guessed the tag was somewhere around here" and one saying + * "this phone heard it from ten metres away" are not interchangeable, and the history gets + * exported. The {@code provenance} column is what keeps them apart. + */ +@RunWith(AndroidJUnit4.class) +public class WritingDownWhereATagWasHeardTest { + + private static final String A_TAG = "a-tag"; + private static final String A_PLIST = ""; + + /** Ilvesheim, where the tags behind this feature actually live. */ + private static final double LAT = 49.4767; + private static final double LON = 8.5622; + + private static final long NOON = 1_700_000_000_000L; + private static final int A_STATUS_BYTE = 0x20; + + private OpenTagViewerDatabase db; + private BeaconRepository repo; + + @Before + public void openAnInMemoryDatabase() { + this.db = Room.inMemoryDatabaseBuilder( + getInstrumentation().getTargetContext(), OpenTagViewerDatabase.class) + .allowMainThreadQueries() + .build(); + + this.repo = new BeaconRepository(this.db, (plist, alignment) -> "{\"type\":\"accessory\"}"); + + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(A_TAG).content(A_PLIST).accessoryJson("{\"type\":\"accessory\"}") + .version("0.0.2").fromAccount(false).isRemoved(false).build()); + } + + @After + public void closeIt() { + this.db.close(); + } + + /** + * @return whether the sighting earned a row. {@code recordLocalSighting} used to answer that + * with a boolean and now hands back the report it wrote, so presence is the same + * answer - every assertion here is still about the decision, not the row. + */ + private boolean record(final double lat, final double lon, final long accuracy, final long at) { + return this.repo.recordLocalSighting(A_TAG, lat, lon, accuracy, A_STATUS_BYTE, at) + .blockingFirst() + .isPresent(); + } + + private List allReports() { + return this.db.locationReportDao() + .getInTimeRange(A_TAG, NOON - 86_400_000L, NOON + 86_400_000L); + } + + @Test + public void aSightingBecomesALocationReportMarkedAsLocal() { + assertTrue(this.record(LAT, LON, 8, NOON)); + + final List reports = this.allReports(); + assertEquals(1, reports.size()); + + final LocationReport report = reports.get(0); + assertEquals(LocationReport.PROVENANCE_LOCAL, report.provenance); + assertEquals(LAT, report.latitude, 0.00001); + assertEquals(LON, report.longitude, 0.00001); + assertEquals(NOON, report.timestamp); + } + + /** + * The accuracy is the fix's own, not a guess. + * + *

It is the field anything comparing two reports reads, and a locally heard position is + * usually an order of magnitude tighter than a network one. Inventing a number here would + * either throw that advantage away or claim precision the fix never had. + */ + @Test + public void theFixesOwnAccuracyIsWhatGetsStored() { + this.record(LAT, LON, 8, NOON); + + assertEquals(8, this.allReports().get(0).horizontalAccuracy); + } + + /** The status byte the tag broadcast rides along, the same field an Apple report carries. */ + @Test + public void theAdvertisedStatusByteIsKeptOnTheReport() { + this.record(LAT, LON, 8, NOON); + + assertEquals(A_STATUS_BYTE, this.allReports().get(0).status); + } + + /** + * The rule that keeps a tag on a desk from filling the history. Sightings arrive every + * couple of seconds and the callback fires once a minute; without this, an evening beside + * somebody would be several hundred rows describing one spot. + */ + @Test + public void standingStillDoesNotWriteASecondRowStraightAway() { + assertTrue(this.record(LAT, LON, 8, NOON)); + assertFalse(this.record(LAT, LON, 8, NOON + 60_000)); + + assertEquals(1, this.allReports().size()); + } + + @Test + public void movingFarEnoughWritesAnotherRow() { + assertTrue(this.record(LAT, LON, 8, NOON)); + assertTrue(this.record(LAT + 0.0008, LON, 8, NOON + 60_000)); + + assertEquals(2, this.allReports().size()); + } + + @Test + public void stayingPutIsWorthRecordingAgainAfterLongEnough() { + assertTrue(this.record(LAT, LON, 8, NOON)); + assertTrue(this.record(LAT, LON, 8, NOON + LocalFixWorthKeeping.AGAIN_AFTER_MS)); + + assertEquals(2, this.allReports().size()); + } + + /** + * A network report must not suppress the local row that supersedes it. + * + *

The two answer different questions: "when did somebody else last see it" and "when did + * I last hear it". Deciding the write rule from the newest report of any kind would + * mean a tag fetched a minute ago never records the far more precise position of being heard + * in the same room. + */ + @Test + public void anAppleReportDoesNotStandInForTheLastLocalOne() { + this.db.locationReportDao().insertAll(LocationReport.builder() + .hashId("an-apple-report") + .beaconId(A_TAG) + .publishedAt(NOON) + .description("Apple") + .timestamp(NOON) + .confidence(2) + .latitude(LAT) + .longitude(LON) + .horizontalAccuracy(120) + .status(0) + .lastUpdate(NOON) + .provenance(LocationReport.PROVENANCE_APPLE) + .build()); + + assertTrue("a fresh network report must not suppress a local sighting", + this.record(LAT, LON, 8, NOON + 1_000)); + } + + /** + * Two sightings of the same tag at the same moment and place collapse to one row. + * + *

The id is a hash of what the report says, so a repeat cannot accumulate - which is what + * keeps a retry or a duplicated callback from doubling the history. + */ + @Test + public void theSameSightingTwiceIsOneRow() { + this.repo.recordLocalSighting(A_TAG, LAT, LON, 8, A_STATUS_BYTE, NOON).blockingFirst(); + this.repo.recordLocalSighting(A_TAG, LAT, LON, 8, A_STATUS_BYTE, NOON).blockingFirst(); + + assertEquals(1, this.allReports().size()); + } + + /** And the map reads it: the newest row per tag is what gets drawn. */ + @Test + public void aLocalReportBecomesTheTagsLatestPosition() { + this.record(LAT, LON, 8, NOON); + + final LocationReport latest = this.db.locationReportDao().getLastFor(A_TAG); + + assertNotNull(latest); + assertEquals(LocationReport.PROVENANCE_LOCAL, latest.provenance); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/LatestReportPerBeaconTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/LatestReportPerBeaconTest.java index d2b15d42..39b41b36 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/LatestReportPerBeaconTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/LatestReportPerBeaconTest.java @@ -176,6 +176,7 @@ private static LocationReport report( .horizontalAccuracy(10L) .status(0) .lastUpdate(timestamp) + .provenance(LocationReport.PROVENANCE_APPLE) .build(); } } diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java index b42b697f..7567c668 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java @@ -457,12 +457,12 @@ public void migrate5To6_handlesEmptyDatabase() throws IOException { * The path an actual user takes, which is never one version at a time. * *

People skip releases, so the upgrade that has to work is v1 straight to the current - * version - five migrations in a row over rows written by a schema none of them were tested + * version - seven migrations in a row over rows written by a schema none of them were tested * against individually. Everything the user owns has to still be there at the end: their * beacons, their location history, and the nicknames they set. */ @Test - public void migrate1To6_directUpgradePreservesEverything() throws IOException { + public void migrate1To8_directUpgradePreservesEverything() throws IOException { try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { insertImport(db, 1L); insertOwnedBeaconV1(db, "beacon-a", 1L, BEACON_PLIST, false); @@ -472,33 +472,177 @@ public void migrate1To6_directUpgradePreservesEverything() throws IOException { } SupportSQLiteDatabase db = helper.runMigrationsAndValidate( - TEST_DB, 6, true, + TEST_DB, 8, true, OpenTagViewerDatabase.MIGRATION_1_2, OpenTagViewerDatabase.MIGRATION_2_3, OpenTagViewerDatabase.MIGRATION_3_4, OpenTagViewerDatabase.MIGRATION_4_5, - OpenTagViewerDatabase.MIGRATION_5_6); + OpenTagViewerDatabase.MIGRATION_5_6, + OpenTagViewerDatabase.MIGRATION_6_7, + OpenTagViewerDatabase.MIGRATION_7_8); try (Cursor cursor = db.query("SELECT COUNT(*) FROM OwnedBeacons")) { assertTrue(cursor.moveToFirst()); - assertEquals("beacons lost on a direct v1 to v6 upgrade", 2, cursor.getInt(0)); + assertEquals("beacons lost on a direct v1 to v8 upgrade", 2, cursor.getInt(0)); } try (Cursor cursor = db.query("SELECT COUNT(*) FROM LocationReport")) { assertTrue(cursor.moveToFirst()); - assertEquals("location history lost on a direct v1 to v6 upgrade", 1, cursor.getInt(0)); + assertEquals("location history lost on a direct v1 to v8 upgrade", 1, cursor.getInt(0)); } try (Cursor cursor = db.query( "SELECT ui_name, ui_order FROM UserBeaconOptions WHERE beacon_id = ?", new Object[] {"beacon-a"})) { - assertTrue("the user's nickname did not survive five migrations", cursor.moveToFirst()); + assertTrue("the user's nickname did not survive seven migrations", cursor.moveToFirst()); assertEquals("Wallet", cursor.getString(0)); assertTrue("nothing may arrive already arranged", cursor.isNull(1)); } } + /** + * v6 to v7 adds an empty table and touches nothing else. + * + *

What it holds is heard by this phone's own radio, so there is nothing to backfill: a tag + * has no sighting until the next time it is actually heard. In particular the accessory + * record's own battery field is not copied across - that value is Apple's, is stale or unset + * for exactly the people this table exists for, and would land here dressed up as something + * this phone had heard. + */ + @Test + public void migrate6To7_addsAnEmptyTableAndBackfillsNothing() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 5)) { + insertImport(db, 1L); + insertOwnedBeaconV5(db, BEACON_ID, 1L, BEACON_PLIST, false); + insertUserBeaconOptions(db, BEACON_ID, "Keys", null); + } + + helper.runMigrationsAndValidate(TEST_DB, 6, true, OpenTagViewerDatabase.MIGRATION_5_6); + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 7, true, OpenTagViewerDatabase.MIGRATION_6_7); + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM LastBleSighting")) { + assertTrue("the new table is missing after the upgrade", cursor.moveToFirst()); + assertEquals("an upgrade must not invent a sighting nobody heard", 0, cursor.getInt(0)); + } + + try (Cursor cursor = db.query( + "SELECT content FROM OwnedBeacons WHERE id = ?", new Object[] {BEACON_ID})) { + assertTrue("the beacon did not survive v6 to v7", cursor.moveToFirst()); + assertEquals(BEACON_PLIST, cursor.getString(0)); + } + + try (Cursor cursor = db.query( + "SELECT ui_name FROM UserBeaconOptions WHERE beacon_id = ?", + new Object[] {BEACON_ID})) { + assertTrue("the user's nickname did not survive v6 to v7", cursor.moveToFirst()); + assertEquals("Keys", cursor.getString(0)); + } + } + + /** + * A sighting written straight after the upgrade reads back, so the table the migration built + * is really the one the app expects - {@code runMigrationsAndValidate} compares the schema, + * and this checks it actually works. + */ + @Test + public void migrate6To7_theNewTableAcceptsASighting() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 5)) { + insertImport(db, 1L); + insertOwnedBeaconV5(db, BEACON_ID, 1L, BEACON_PLIST, false); + } + + helper.runMigrationsAndValidate(TEST_DB, 6, true, OpenTagViewerDatabase.MIGRATION_5_6); + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 7, true, OpenTagViewerDatabase.MIGRATION_6_7); + + db.execSQL("INSERT INTO LastBleSighting" + + " (beacon_id, heard_at, battery_level, status_byte) VALUES (?, ?, ?, ?)", + new Object[] {BEACON_ID, 1700000000000L, "MEDIUM", 0b0100_0000}); + + try (Cursor cursor = db.query( + "SELECT heard_at, battery_level, status_byte FROM LastBleSighting" + + " WHERE beacon_id = ?", new Object[] {BEACON_ID})) { + assertTrue(cursor.moveToFirst()); + assertEquals(1700000000000L, cursor.getLong(0)); + assertEquals("MEDIUM", cursor.getString(1)); + assertEquals(0b0100_0000, cursor.getInt(2)); + } + } + + @Test + public void migrate6To7_handlesEmptyDatabase() throws IOException { + helper.createDatabase(TEST_DB, 5).close(); + + helper.runMigrationsAndValidate(TEST_DB, 6, true, OpenTagViewerDatabase.MIGRATION_5_6); + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 7, true, OpenTagViewerDatabase.MIGRATION_6_7); + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM LastBleSighting")) { + assertTrue(cursor.moveToFirst()); + assertEquals(0, cursor.getInt(0)); + } + } + + /** + * v7 to v8 marks every existing report as Apple's, which is what they all are. + * + *

Local rows could not exist before the column did, so the default is not a fallback but + * the truth. Getting it wrong in the other direction would be worse than it looks: the CSV + * export would hand somebody a file claiming their own phone had recorded positions it never + * took. + */ + @Test + public void migrate7To8_marksExistingReportsAsComingFromApple() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 5)) { + insertImport(db, 1L); + insertOwnedBeaconV5(db, BEACON_ID, 1L, BEACON_PLIST, false); + insertLocationReport(db, "hash-1", BEACON_ID, 1700000000000L); + } + + helper.runMigrationsAndValidate(TEST_DB, 6, true, OpenTagViewerDatabase.MIGRATION_5_6); + helper.runMigrationsAndValidate(TEST_DB, 7, true, OpenTagViewerDatabase.MIGRATION_6_7); + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 8, true, OpenTagViewerDatabase.MIGRATION_7_8); + + try (Cursor cursor = db.query( + "SELECT provenance FROM LocationReport WHERE hash_id = ?", + new Object[] {"hash-1"})) { + assertTrue("the report did not survive v7 to v8", cursor.moveToFirst()); + assertEquals("apple", cursor.getString(0)); + } + } + + /** And a local row can be written straight after the upgrade. */ + @Test + public void migrate7To8_theColumnAcceptsALocalRow() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 5)) { + insertImport(db, 1L); + insertOwnedBeaconV5(db, BEACON_ID, 1L, BEACON_PLIST, false); + } + + helper.runMigrationsAndValidate(TEST_DB, 6, true, OpenTagViewerDatabase.MIGRATION_5_6); + helper.runMigrationsAndValidate(TEST_DB, 7, true, OpenTagViewerDatabase.MIGRATION_6_7); + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 8, true, OpenTagViewerDatabase.MIGRATION_7_8); + + db.execSQL("INSERT INTO LocationReport (hash_id, beacon_id, published_at, description," + + " timestamp, confidence, latitude, longitude, horizontal_accuracy," + + " status, last_update, provenance)" + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + new Object[] {"hash-local", BEACON_ID, 1700000000000L, "Heard over Bluetooth", + 1700000000000L, 0, 49.4767, 8.5622, 8, 32, 1700000000000L, "local"}); + + try (Cursor cursor = db.query( + "SELECT provenance, horizontal_accuracy FROM LocationReport WHERE hash_id = ?", + new Object[] {"hash-local"})) { + assertTrue(cursor.moveToFirst()); + assertEquals("local", cursor.getString(0)); + assertEquals(8, cursor.getInt(1)); + } + } + /** * A beacon as v4 and v5 store one. * diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/DeviceInfoBluetoothSectionLayoutTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/DeviceInfoBluetoothSectionLayoutTest.java new file mode 100644 index 00000000..212e8342 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/DeviceInfoBluetoothSectionLayoutTest.java @@ -0,0 +1,197 @@ +package dev.wander.android.opentagviewer.ui; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.content.res.Configuration; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.R; + +/** + * The "Over Bluetooth" section on the device screen: what this phone's own radio heard from the + * tag, as opposed to everything above it, which is the accessory record Apple keeps. + * + *

Hidden is its resting state, and that is the part worth pinning. The section is only + * filled in once the tag has actually been heard, and it deliberately never falls back to the + * iCloud battery value - the whole reason it sits outside the debug panel is that it says where + * its numbers came from. A stray edit making it visible by default would put an empty section on + * every device screen, reading as "nothing detected", which is a claim nobody here can make. + * + *

Inflation only: no activity, no account, no Bluetooth. Run with + * {@code ./gradlew :app:testEmulatorDebugAndroidTest}. + */ +@RunWith(AndroidJUnit4.class) +public class DeviceInfoBluetoothSectionLayoutTest { + + private static final int SCREEN_WIDTH_PX = 1080; + + /** + * Every piece the section is made of, since they are shown and hidden together. + * + *

The divider and the heading are in here on purpose: leaving either behind when the rows + * go would put a titled, empty section on the screen, which is the failure this whole class + * is about. + */ + private static final int[] SECTION_VIEWS = { + R.id.device_ble_divider, + R.id.device_ble_header, + R.id.device_settings_ble_last_seen, + R.id.device_settings_ble_signal, + R.id.device_settings_ble_battery, + }; + + private Context context; + + @Before + public void setUp() { + this.context = new ContextThemeWrapper( + getInstrumentation().getTargetContext(), R.style.Theme_OpenTagViewer); + } + + private View inflateDeviceInfo() { + final View[] root = new View[1]; + getInstrumentation().runOnMainSync(() -> + root[0] = LayoutInflater.from(this.context) + .inflate(R.layout.activity_device_info, null)); + return root[0]; + } + + @Test + public void theScreenStillInflates() { + assertNotNull(this.inflateDeviceInfo()); + } + + /** + * Every id {@code DeviceInfoActivity.showBluetoothSection} looks up has to resolve, or that + * piece is simply never shown and nothing fails. + */ + @Test + public void theWholeSectionExists() { + final View screen = this.inflateDeviceInfo(); + + for (final int id : SECTION_VIEWS) { + assertNotNull("missing view in the Over Bluetooth section", screen.findViewById(id)); + } + } + + @Test + public void theSectionIsHiddenUntilTheTagIsHeard() { + final View screen = this.inflateDeviceInfo(); + + for (final int id : SECTION_VIEWS) { + assertEquals("a tag this phone has never heard must show no section at all," + + " not an empty one", + View.GONE, screen.findViewById(id).getVisibility()); + } + } + + /** Shown, each row has to occupy real space rather than measuring to nothing. */ + @Test + public void theRowsHaveRealSizeOnceShown() { + final int[][] size = new int[SECTION_VIEWS.length][2]; + + getInstrumentation().runOnMainSync(() -> { + final View screen = LayoutInflater.from(this.context) + .inflate(R.layout.activity_device_info, null); + + for (final int id : SECTION_VIEWS) { + screen.findViewById(id).setVisibility(View.VISIBLE); + } + + screen.measure( + View.MeasureSpec.makeMeasureSpec(SCREEN_WIDTH_PX, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(2400, View.MeasureSpec.EXACTLY)); + screen.layout(0, 0, SCREEN_WIDTH_PX, 2400); + + for (int i = 0; i < SECTION_VIEWS.length; i++) { + final View view = screen.findViewById(SECTION_VIEWS[i]); + size[i][0] = view.getMeasuredWidth(); + size[i][1] = view.getMeasuredHeight(); + } + }); + + for (int i = 0; i < SECTION_VIEWS.length; i++) { + assertTrue("view " + i + " measured " + size[i][0] + "x" + size[i][1], + size[i][0] > 0 && size[i][1] > 0); + } + } + + /** + * It sits outside the debug panel, which is the whole point. Inside it, these readings + * would be invisible to everyone who has not turned debug data on, and the section exists + * because for somebody with no Apple device this is the only battery figure there is. + */ + @Test + public void theSectionIsNotInsideTheDebugPanel() { + final View screen = this.inflateDeviceInfo(); + final ViewGroup debugPanel = screen.findViewById(R.id.device_debug_info); + + assertNotNull(debugPanel); + for (final int id : SECTION_VIEWS) { + assertTrue("what the radio heard must not be gated behind the debug switch", + debugPanel.findViewById(id) == null); + } + } + + /** Half of what breaks only breaks in one mode. */ + @Test + public void theSectionSurvivesDarkMode() { + final Configuration night = new Configuration( + this.context.getResources().getConfiguration()); + night.uiMode = Configuration.UI_MODE_NIGHT_YES | Configuration.UI_MODE_TYPE_NORMAL; + + final Context darkContext = new ContextThemeWrapper( + this.context.createConfigurationContext(night), R.style.Theme_OpenTagViewer); + + final View[] screen = new View[1]; + getInstrumentation().runOnMainSync(() -> screen[0] = LayoutInflater.from(darkContext) + .inflate(R.layout.activity_device_info, null)); + + for (final int id : SECTION_VIEWS) { + final View view = screen[0].findViewById(id); + assertNotNull(view); + assertEquals(View.GONE, view.getVisibility()); + } + } + + /** + * The short battery words are what this section and the tag card show. The debug panel's own + * strings spell out percentage ranges and a caveat, which is right there and far too long for + * a one-line row - so this pins that they stayed short. + */ + @Test + public void theShortBatteryWordsStayShortEnoughForARow() { + for (final int id : new int[] { + R.string.battery_short_full, + R.string.battery_short_medium, + R.string.battery_short_low, + R.string.battery_short_very_low, + }) { + final String word = this.context.getString(id); + assertTrue("\"" + word + "\" is too long for a tag card line", word.length() <= 20); + } + } + + /** The card line reads e.g. "Nearby · Battery full", so the format has to take the word. */ + @Test + public void theNearbyLineFormatsWithABatteryWord() { + final String line = this.context.getString(R.string.nearby_now_with_battery, + this.context.getString(R.string.battery_short_low)); + + assertTrue("the battery word should appear in the line: " + line, + line.contains(this.context.getString(R.string.battery_short_low))); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/NothingSitsUnderTheNavigationBarTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/NothingSitsUnderTheNavigationBarTest.java new file mode 100644 index 00000000..e9506e5b --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/NothingSitsUnderTheNavigationBarTest.java @@ -0,0 +1,148 @@ +package dev.wander.android.opentagviewer.ui.compat; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; + +import android.content.Context; +import android.content.Intent; +import android.widget.FrameLayout; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.List; + +import dev.wander.android.opentagviewer.AppleLoginActivity; +import dev.wander.android.opentagviewer.DeviceInfoActivity; +import dev.wander.android.opentagviewer.HistoryViewActivity; +import dev.wander.android.opentagviewer.InformationActivity; +import dev.wander.android.opentagviewer.MyDevicesListActivity; +import dev.wander.android.opentagviewer.SettingsActivity; +import dev.wander.android.opentagviewer.ui.error.ErrorReportActivity; +import dev.wander.android.opentagviewer.ui.maps.AMapWithTagsOnIt; + +/** + * Nothing a person has to press ends up underneath the navigation bar. + * + *

The bug, in @parawanderer's words: "any button we put at the bottom of the page (or + * random text) is barely to not clickable". The screenshot was the keychain unlock screen, its + * Unlock button behind the gesture pill. The theme draws under a transparent navigation bar, so + * a screen that does not pad for it puts its last control where the system takes the touches. + * + *

{@link TheNavigationBar} does the asking, and explains why the bar is invented rather than + * measured. This class brings the screens. + * + *

Every activity in the manifest except two. The map is out deliberately - it draws + * tiles edge to edge under the bar and pads only the card row above it, which + * {@code TagCardLayoutTest} covers. The iCloud flow is checked in + * {@code FetchFromICloudFlowTest} instead, because it closes itself without a usable session and + * that test already knows how to give it one; rebuilding that here would be the copy that drifts. + * + *

The list is still the weak point. A screen added later is not covered until somebody + * adds it - the same rot that let this happen. What protects the common case is that + * {@link WindowPaddingUtil#insetForSystemBars} does both bars at once, so a screen cannot handle + * the status bar and silently miss the navigation bar; the top-only helper it replaced is gone + * rather than left there to be called. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class NothingSitsUnderTheNavigationBarTest { + + /** Screens that open with nothing arranged. */ + private static final Class[] SCREENS_THAT_NEED_NOTHING = { + AppleLoginActivity.class, + MyDevicesListActivity.class, + InformationActivity.class, + SettingsActivity.class, + }; + + private final AMapWithTagsOnIt theMap = new AMapWithTagsOnIt(); + + @After + public void putItBack() { + this.theMap.putItBack(); + } + + /** + * The signed-out screens first, before anything stores a session. + * + *

{@code AMapWithTagsOnIt.seed} writes one, and {@code AppleLoginActivity} finishes itself + * and leaves for the map the moment one exists - so checking it after seeding gave "Activity + * has been destroyed already" rather than an answer. Two phases, in this order, for that + * reason alone. + */ + @Test + public void everyScreenKeepsItsControlsAboveTheNavigationBar() { + final Context context = getInstrumentation().getTargetContext(); + + final List beforeAnybodySignsIn = new ArrayList<>(); + for (final Class screen : SCREENS_THAT_NEED_NOTHING) { + beforeAnybodySignsIn.add(new Intent(context, screen)); + } + + // **The report page, which is what asking this question properly turned up.** It handled + // neither bar, and a search-and-replace over the screens that padded for the status bar + // could not find it precisely because it did none of it. Its Close and Share buttons are + // the last things on a scrolling page. + beforeAnybodySignsIn.add( + ErrorReportActivity.intentFor(context, "a made-up failure, for the test")); + + this.check(beforeAnybodySignsIn); + + // And the two that need a tag to look at, which comes with a session. + this.theMap.seed("A tag"); + final String beaconId = this.theMap.tagIds().get(0); + + this.check(List.of( + new Intent(context, DeviceInfoActivity.class).putExtra("beaconId", beaconId), + new Intent(context, HistoryViewActivity.class).putExtra("beaconId", beaconId))); + } + + private void check(final List screens) { + for (final Intent screen : screens) { + final String name = screen.getComponent().getShortClassName(); + + try (ActivityScenario scenario = ActivityScenario.launch(screen)) { + TheNavigationBar.doesNotCover(scenario, name); + } + } + } + + /** + * And applying them twice does not double the gap. + * + *

Insets are delivered more than once - a rotation, the keyboard opening, somebody + * switching to three-button navigation - so a helper that added the inset to whatever padding + * it found would grow the gap on every delivery. It reads its own starting padding once, and + * this is what says so. + */ + @Test + public void repeatedInsetsDoNotAccumulate() { + final int[] afterOne = new int[1]; + final int[] afterThree = new int[1]; + + getInstrumentation().runOnMainSync(() -> { + final FrameLayout view = new FrameLayout(getInstrumentation().getTargetContext()); + view.setPadding(0, 0, 0, 17); + WindowPaddingUtil.insetForSystemBars(view); + + TheNavigationBar.putABarUnder(view); + afterOne[0] = view.getPaddingBottom(); + + TheNavigationBar.putABarUnder(view); + TheNavigationBar.putABarUnder(view); + afterThree[0] = view.getPaddingBottom(); + }); + + assertEquals("the view's own 17px should be kept, with the bar's height added to it", + 17 + TheNavigationBar.A_TALL_ONE, afterOne[0]); + assertEquals("three deliveries of the same insets must leave the same padding as one", + afterOne[0], afterThree[0]); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/TheNavigationBar.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/TheNavigationBar.java new file mode 100644 index 00000000..42b1b7e5 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/TheNavigationBar.java @@ -0,0 +1,202 @@ +package dev.wander.android.opentagviewer.ui.compat; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertNull; + +import android.app.Activity; +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; + +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.test.core.app.ActivityScenario; + +import java.util.ArrayList; +import java.util.List; + +/** + * Asks a screen whether it would keep its controls out of a navigation bar. + * + *

The bar is invented, and that is the point. The managed device this suite runs on + * reports a {@code systemBars} bottom inset of zero - measured, while chasing a different + * bug - so asking the real device where its navigation bar is proves nothing on CI, and a + * geometric assertion would pass on any layout at all. Dispatching a synthetic inset asks the + * question that actually matters: if there were a bar this tall, would this screen keep + * its buttons out of it? Same answer on every device. + * + *

Here rather than in one test class because the screens live in different setups. Most + * open with nothing arranged; the iCloud flow needs a session its own flow test knows how to + * build. Copying the check into that test would be the version that drifts, so the check is + * shared and each test brings its own screen. + */ +public final class TheNavigationBar { + + /** Taller than any real navigation bar, so a screen that ignores it cannot pass by luck. */ + public static final int A_TALL_ONE = 240; + + private static final int A_STATUS_BAR = 90; + + private TheNavigationBar() { + } + + /** + * Pretend this screen has a tall navigation bar, and fail if anything a person must press + * would end up underneath it. + * + *

Takes the scenario, not the activity, and that is not a style choice. This has to + * hop to the main thread to touch views and back off it to wait for a layout pass, and + * {@code runOnMainSync} throws "This method can not be called from the main application + * thread" if it is already there. Handing it an {@code Activity} invited exactly that: the + * obvious way to get one is inside {@code onActivity}, which is on the main thread. + */ + public static void doesNotCover(final ActivityScenario scenario, final String screen) { + scenario.onActivity(activity -> putABarUnder(activity.findViewById(android.R.id.content))); + getInstrumentation().waitForIdleSync(); + + final String[] problem = new String[1]; + scenario.onActivity(activity -> { + problem[0] = whatSitsUnderIt(activity, screen); + if (problem[0] == null) { + problem[0] = didNothingReserveIt(activity, screen); + } + }); + + assertNull(problem[0], problem[0]); + } + + public static void putABarUnder(final View view) { + ViewCompat.dispatchApplyWindowInsets(view, new WindowInsetsCompat.Builder() + .setInsets(WindowInsetsCompat.Type.systemBars(), + Insets.of(0, A_STATUS_BAR, 0, A_TALL_ONE)) + .build()); + } + + /** @return a description of the first control found inside the bar, or null if all is well. */ + private static String whatSitsUnderIt(final Activity activity, final String screen) { + final View root = activity.findViewById(android.R.id.content); + final int barStartsAt = root.getHeight() - A_TALL_ONE; + + for (final View control : clickableThingsIn(root)) { + if (control == root) { + continue; + } + + final Rect bounds = new Rect(0, 0, control.getWidth(), control.getHeight()); + ((ViewGroup) root).offsetDescendantRectToMyCoords(control, bounds); + + // **A full-height container that happens to be clickable is not a control.** + // InformationActivity's root carries android:clickable and spans the screen, so it + // "ends below the bar" by definition - and it is the very view whose padding keeps + // the real controls out of the bar. Judging it as a button failed a correct screen. + if (bounds.height() > root.getHeight() * 0.7) { + continue; + } + + // **Something inside a scrolling list is not stuck there.** Settings' last switches + // sit under the bar at rest and a flick brings them up, which is ordinary and fine. + // The complaint is about controls that cannot be moved - a button anchored at the + // bottom, like the keychain Unlock button in the report. + if (canBeScrolledClear(control, root)) { + continue; + } + + if (bounds.bottom > barStartsAt) { + return screen + ": a control ends at " + bounds.bottom + " but the navigation bar" + + " starts at " + barStartsAt + " (" + describe(activity, control) + ")" + + " - it would be under the bar and hard or impossible to press"; + } + } + return null; + } + + /** + * And the screen has to reserve the bar's height somewhere. + * + *

The check above only looks at controls that exist and are anchored, so a screen whose + * bottom happens to be empty passes it while handling no insets at all - and then puts a + * button under the bar the moment somebody adds one. + * + *

Loose on purpose: which view holds the padding differs by screen - the root on most, the + * scroll container on the iCloud flow - and pinning that per screen would be one more + * per-screen thing to keep in step. + */ + private static String didNothingReserveIt(final Activity activity, final String screen) { + for (final View view : everythingIn(activity.findViewById(android.R.id.content))) { + if (view.getPaddingBottom() >= A_TALL_ONE) { + return null; + } + } + return screen + ": nothing on this screen reserved the " + A_TALL_ONE + "px navigation" + + " bar, so it is not handling window insets at all - anything put at the bottom" + + " of it will end up under the bar"; + } + + private static boolean canBeScrolledClear(final View control, final View root) { + for (ViewGroup parent = (ViewGroup) control.getParent(); + parent != null && parent != root.getParent(); + parent = parent.getParent() instanceof ViewGroup + ? (ViewGroup) parent.getParent() : null) { + + if (parent instanceof android.widget.ScrollView + || parent instanceof android.widget.HorizontalScrollView + || parent instanceof androidx.core.widget.NestedScrollView + || parent instanceof androidx.recyclerview.widget.RecyclerView + || parent instanceof android.widget.ListView) { + return true; + } + + // **A bottom sheet is dragged, which is the same kind of movable.** The history + // sheet's retry button measures inside the bar at rest, and a drag upwards brings it + // out - so it is not the stuck button this is about. + // + // Known gap, deliberately: a sheet is positioned by its BottomSheetBehavior rather + // than by its parent's padding, so the screen-level inset does not reach into it and + // padding the sheet's own content did not move it either - measured, both ways. Doing + // it properly means the behaviour's peek height, which is a bigger change than the + // one this test was written for. + if (parent.getLayoutParams() + instanceof androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams + && ((androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams) + parent.getLayoutParams()).getBehavior() != null) { + return true; + } + } + return false; + } + + private static List clickableThingsIn(final View view) { + final List found = new ArrayList<>(); + if (view.getVisibility() == View.VISIBLE && view.isClickable() && view.getWidth() > 0) { + found.add(view); + } + if (view instanceof ViewGroup) { + final ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + found.addAll(clickableThingsIn(group.getChildAt(i))); + } + } + return found; + } + + private static List everythingIn(final View view) { + final List found = new ArrayList<>(); + found.add(view); + if (view instanceof ViewGroup) { + final ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + found.addAll(everythingIn(group.getChildAt(i))); + } + } + return found; + } + + private static String describe(final Activity activity, final View view) { + try { + return activity.getResources().getResourceEntryName(view.getId()); + } catch (final Exception noName) { + return view.getClass().getSimpleName(); + } + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/history/TheHistoryScreenDrawsTheDayTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/history/TheHistoryScreenDrawsTheDayTest.java index bd7ebc5e..7c8fe7ae 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/history/TheHistoryScreenDrawsTheDayTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/history/TheHistoryScreenDrawsTheDayTest.java @@ -556,6 +556,7 @@ private void givenReportsOn(final int daysBack, final double[][] positions, .horizontalAccuracy(83) .status(144) .lastUpdate(at) + .provenance(LocationReport.PROVENANCE_APPLE) .build()); } diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/AMapWithTagsOnIt.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/AMapWithTagsOnIt.java index 58843ee6..fea197ce 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/AMapWithTagsOnIt.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/AMapWithTagsOnIt.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Map; +import dev.wander.android.opentagviewer.ble.BlePermissions; import dev.wander.android.opentagviewer.DeviceStateGuard; import dev.wander.android.opentagviewer.MapsActivity; import dev.wander.android.opentagviewer.R; @@ -200,6 +201,7 @@ public AMapWithTagsOnIt seed(final String... names) { .horizontalAccuracy(83) .status(144) .lastUpdate(reportedAt) + .provenance(LocationReport.PROVENANCE_APPLE) .build()); } @@ -257,13 +259,29 @@ public AMapWithTagsOnIt seed(final String... names) { *

Done here rather than with a {@code GrantPermissionRule} in each test, because a rule * is per-class and this is a property of arranging the map at all - a new test that used the * fixture and forgot the rule would hit the same six-minute wall. + * + *

Bluetooth is on this list for the same reason, and it arrived later. + * {@code startWatchingForNearbyTags} asks for the scan permissions as the map opens - so the + * badges on the cards work without somebody pressing ring first - and its own comment notes + * that the system dialog pauses the activity. With only location granted, the map opened + * behind that dialog and the root picker span on "No activity currently resumed" until the + * job's own limit: not six minutes this time but forty-five, because there is no per-test + * timeout to stop it. It never failed, it just never ended. + * + *

Taken from {@link BlePermissions#required()} rather than named here, because which + * permissions those are depends on the API level - {@code BLUETOOTH_SCAN} and + * {@code BLUETOOTH_CONNECT} from S, and fine location before it. Naming them twice is how + * the copy that is wrong goes unnoticed. */ private void grantLocationUpFront() { final String packageName = this.context.getPackageName(); - for (final String permission : new String[] { + final List needed = new ArrayList<>(List.of( android.Manifest.permission.ACCESS_FINE_LOCATION, - android.Manifest.permission.ACCESS_COARSE_LOCATION}) { + android.Manifest.permission.ACCESS_COARSE_LOCATION)); + needed.addAll(List.of(BlePermissions.required())); + + for (final String permission : needed) { try { getInstrumentation().getUiAutomation() .grantRuntimePermission(packageName, permission); diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java index 75d9135a..f960ef19 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java @@ -8,6 +8,7 @@ import android.content.Context; import android.content.res.Configuration; +import android.graphics.Rect; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -169,6 +170,51 @@ public void aLongDeviceNameDoesNotMakeItsCardTaller() { assertEquals(heights.get(0), heights.get(1)); } + /** + * {@code MapsActivity.showNearbyStatusOn}'s longest realistic line - full signal and the + * longest battery word - must not wrap to a second line and grow the row. Built directly + * rather than through {@link #measureHeights}, which always writes a fixed string to this + * field. + */ + @Test + public void theLongestNearbyStatusLineDoesNotMakeItsCardTaller() { + final int[] heights = new int[2]; + + getInstrumentation().runOnMainSync(() -> { + final FrameLayout baseline = (FrameLayout) LayoutInflater.from(this.context) + .inflate(R.layout.maps_tag_card, null); + final FrameLayout withNearbyStatus = (FrameLayout) LayoutInflater.from(this.context) + .inflate(R.layout.maps_tag_card, null); + + for (final FrameLayout card : new FrameLayout[]{baseline, withNearbyStatus}) { + ((TextView) card.findViewById(R.id.device_name)).setText("Keys"); + ((TextView) card.findViewById(R.id.device_location)).setText(SHORT_ADDRESS); + // Inflated with a null root, so there is no parent-given LayoutParams to read + // back - unlike measureHeights, which sets these after row.addView(card). + card.setLayoutParams(new ViewGroup.LayoutParams( + CARD_WIDTH_PX, ViewGroup.LayoutParams.WRAP_CONTENT)); + } + + ((TextView) baseline.findViewById(R.id.device_last_update)) + .setText("Last Updated: 2 minutes ago"); + // "critical" (English) / "kritisch" (German) is the longest battery word; five + // filled dots is the longest signal reading; three digits covers up to the 30 + // second freshness window in NearbyTagSightings with room to spare. + ((TextView) withNearbyStatus.findViewById(R.id.device_last_update)) + .setText("Nearby (●●●●●) · Battery critical"); + + for (final FrameLayout card : new FrameLayout[]{baseline, withNearbyStatus}) { + card.measure( + View.MeasureSpec.makeMeasureSpec(CARD_WIDTH_PX, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + } + heights[0] = baseline.getMeasuredHeight(); + heights[1] = withNearbyStatus.getMeasuredHeight(); + }); + + assertEquals("the nearby status line wrapped and grew the card", heights[0], heights[1]); + } + /** * Why the height has to be set at all. *
@@ -471,17 +517,83 @@ private List measureIconVariantHeights(final float fontScale) { * card's height is fixed by its shortest neighbour - so the failure is a row of icons with * the words clipped away, on one card out of four. * - *

Ring is not among them, and that is deliberate. Its container is - * {@code visibility="gone"} in the layout because the feature does not exist - FindMy.py has - * no ring implementation - so it measures nothing. Pinned separately below rather than - * quietly skipped here. + *

Ring is included alongside the other three - see + * {@code dev.wander.android.opentagviewer.ble} for what is behind it now. + */ + /** + * The four actions are evenly spaced across the row. + * + *

They are laid out with {@code layout_weight="1"} apiece, which makes them equal in + * width and says nothing about where they sit: the margins between them are what puts + * them on an even pitch, and one container missing a pair of them shifts every gap around it + * without changing any width. Ring shipped without its 8dp margins, which pushed the first + * gap 8dp wider than the other two - visible on a phone as Location History sitting too far + * from Refresh, and invisible to + * {@link #everyActionOnTheCardStillHasRoomWithTheWorstContent}, which asks about sizes. + * + *

Measured between the centres of the icons rather than the containers, because the icon + * is the thing a person's eye lines up. + * + *

The tolerance is in dp, and it is the difference between a test and a nuisance. + * Four weighted columns rarely divide a card width exactly, so neighbouring gaps land a + * pixel or two apart - measured at 243 and 245 here - and a 1px tolerance fails on that + * while proving nothing. A missing margin is 8dp, which is four times this threshold at any + * density, so the gap between "rounding" and "the bug" is wide and this sits in it. */ + @Test + public void theFourActionsAreEvenlySpacedAcrossTheRow() { + final int[] centres = new int[4]; + final int[] iconIds = { + R.id.history_icon, + R.id.refresh_icon, + R.id.perform_ring_icon, + R.id.tag_more_icon, + }; + + getInstrumentation().runOnMainSync(() -> { + final FrameLayout card = (FrameLayout) LayoutInflater.from(this.context) + .inflate(R.layout.maps_tag_card, null); + + final int width = cardWidthFor(SCREEN_WIDTH_PX); + card.measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + card.layout(0, 0, width, card.getMeasuredHeight()); + + for (int i = 0; i < iconIds.length; i++) { + final View icon = card.findViewById(iconIds[i]); + + // **Offsets within the card, not window coordinates.** This card is inflated + // with no parent and never attached, so getLocationInWindow has no window to + // answer about and reports positions that are all but identical - which came + // out as negative gaps rather than as an obvious "this measured nothing". + final Rect bounds = new Rect(0, 0, icon.getWidth(), icon.getHeight()); + card.offsetDescendantRectToMyCoords(icon, bounds); + centres[i] = bounds.centerX(); + } + }); + + final float density = this.context.getResources().getDisplayMetrics().density; + final int roundingSlackPx = Math.round(2 * density); + + final int firstGap = centres[1] - centres[0]; + for (int i = 1; i < centres.length - 1; i++) { + final int gap = centres[i + 1] - centres[i]; + assertTrue("the gap between action " + i + " and " + (i + 1) + " is " + gap + + "px, but the first gap is " + firstGap + "px - the row is not on an" + + " even pitch, which usually means one container is missing the 8dp" + + " margins the others have", + Math.abs(gap - firstGap) <= roundingSlackPx); + } + } + @Test public void everyActionOnTheCardStillHasRoomWithTheWorstContent() { - final int[][] sizes = new int[3][2]; + final int[][] sizes = new int[4][2]; final int[] buttonIds = { R.id.device_history_button_container, R.id.device_refresh_button_container, + R.id.device_ring_button_container, R.id.device_more_button_container, }; @@ -514,7 +626,10 @@ public void everyActionOnTheCardStillHasRoomWithTheWorstContent() { // They share the row, so a card that has run out of width shows up as one of them being // visibly smaller than the rest rather than as anything failing. - final int widest = Math.max(Math.max(sizes[0][0], sizes[1][0]), sizes[2][0]); + int widest = 0; + for (final int[] size : sizes) { + widest = Math.max(widest, size[0]); + } for (int i = 0; i < buttonIds.length; i++) { assertTrue("button " + i + " is " + sizes[i][0] + "px against a widest of " + widest + ", so the row is no longer sharing the width evenly", @@ -523,16 +638,17 @@ public void everyActionOnTheCardStillHasRoomWithTheWorstContent() { } /** - * Ring is hidden, because there is nothing behind it. + * Ring is shown by default, at rest. * - *

{@code onClickRing} logs and returns: FindMy.py cannot ring an accessory, so the - * control exists in the layout and is switched off. This is here so that stops being true - * on purpose rather than by accident - a stray edit making it visible ships a button that - * does nothing at all, which is worse than not offering it. + *

It used to be {@code visibility="gone"} because nothing implemented it. Now + * {@code MapsActivity#onClickRing} does (continuous ping over BLE, see + * {@code dev.wander.android.opentagviewer.ble}), so this is the opposite pin from before: a + * stray edit hiding it again ships a card silently missing an action, rather than a card + * offering one that does nothing. */ @Test - public void theRingButtonStaysHiddenWhileThereIsNothingBehindIt() { - final int[] visibility = {View.VISIBLE}; + public void theRingButtonIsShownAtRestByDefault() { + final int[] visibility = {View.GONE}; getInstrumentation().runOnMainSync(() -> { final FrameLayout card = (FrameLayout) LayoutInflater.from(this.context) @@ -540,7 +656,67 @@ public void theRingButtonStaysHiddenWhileThereIsNothingBehindIt() { visibility[0] = card.findViewById(R.id.device_ring_button_container).getVisibility(); }); - assertEquals("Ring is showing, but nothing implements it - see MapsActivity#onClickRing", - View.GONE, visibility[0]); + assertEquals("Ring is hidden, but MapsActivity#onClickRing now implements it", + View.VISIBLE, visibility[0]); + } + + /** The label at rest, before anyone has tapped it - see {@link TagCardHelper#toggleRingActive}. */ + @Test + public void theRingButtonStartsLabelledRing() { + final String[] text = {null}; + + getInstrumentation().runOnMainSync(() -> { + final FrameLayout card = (FrameLayout) LayoutInflater.from(this.context) + .inflate(R.layout.maps_tag_card, null); + text[0] = ((TextView) card.findViewById(R.id.ringText)).getText().toString(); + }); + + assertEquals(this.context.getString(R.string.do_ring), text[0]); + } + + /** + * {@link TagCardHelper#toggleRingActive} is what MapsActivity calls on tap, and on stop. + * + *

Round-tripped in one test rather than two, because the failure that matters is the + * button getting stuck in one state - which only shows up by going there and back. + */ + @Test + public void toggleRingActiveSwapsTheLabelBothWays() { + final String[] activeText = {null}; + final String[] inactiveAgainText = {null}; + + getInstrumentation().runOnMainSync(() -> { + final FrameLayout card = (FrameLayout) LayoutInflater.from(this.context) + .inflate(R.layout.maps_tag_card, null); + + TagCardHelper.toggleRingActive(card, true); + activeText[0] = ((TextView) card.findViewById(R.id.ringText)).getText().toString(); + + TagCardHelper.toggleRingActive(card, false); + inactiveAgainText[0] = ((TextView) card.findViewById(R.id.ringText)).getText().toString(); + }); + + assertEquals(this.context.getString(R.string.stop_ringing), activeText[0]); + assertEquals(this.context.getString(R.string.do_ring), inactiveAgainText[0]); + } + + /** + * {@link TagCardHelper#setRingLabel} is what continuous ping updates between taps - + * "Scanning...", "Connecting...", "Sending..." - without touching the icon or tint + * {@link TagCardHelper#toggleRingActive} owns. See {@code MapsActivity#handleContinuousPingUpdate}. + */ + @Test + public void setRingLabelChangesOnlyTheText() { + final String[] text = {null}; + + getInstrumentation().runOnMainSync(() -> { + final FrameLayout card = (FrameLayout) LayoutInflater.from(this.context) + .inflate(R.layout.maps_tag_card, null); + + TagCardHelper.setRingLabel(card, "Scanning…"); + text[0] = ((TextView) card.findViewById(R.id.ringText)).getText().toString(); + }); + + assertEquals("Scanning…", text[0]); } } diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TheMapDrawsWhatIsStoredTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TheMapDrawsWhatIsStoredTest.java index 522db1ba..24262538 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TheMapDrawsWhatIsStoredTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TheMapDrawsWhatIsStoredTest.java @@ -144,6 +144,7 @@ public void seedTwoTagsAndSubstituteTheMap() { .horizontalAccuracy(83) .status(144) .lastUpdate(1_700_000_000_000L) + .provenance(LocationReport.PROVENANCE_APPLE) .build()); // **Otherwise the startup fetch is skipped.** RefreshPolicy is a process-wide singleton diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/TheDeviceListNoticesNewLocationsTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/TheDeviceListNoticesNewLocationsTest.java index 6b4e540c..4b279a63 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/TheDeviceListNoticesNewLocationsTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/TheDeviceListNoticesNewLocationsTest.java @@ -124,6 +124,7 @@ private void givenHistoryWasFetchedWhileTheListWasAway() { .horizontalAccuracy(83) .status(144) .lastUpdate(System.currentTimeMillis()) + .provenance(LocationReport.PROVENANCE_APPLE) .build()); } diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/TheICloudOfferAppearsOnceTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/TheICloudOfferAppearsOnceTest.java index 1757369a..7aade8a7 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/TheICloudOfferAppearsOnceTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/TheICloudOfferAppearsOnceTest.java @@ -16,6 +16,7 @@ import android.content.Context; import android.content.Intent; +import androidx.lifecycle.Lifecycle; import androidx.test.core.app.ActivityScenario; import androidx.test.espresso.NoMatchingRootException; import androidx.test.espresso.NoMatchingViewException; @@ -39,6 +40,7 @@ import dev.wander.android.opentagviewer.python.AppDependencies; import dev.wander.android.opentagviewer.Eventually; import dev.wander.android.opentagviewer.FetchFromICloudActivity; +import dev.wander.android.opentagviewer.ui.error.ErrorReportActivity; import dev.wander.android.opentagviewer.MapsActivity; import dev.wander.android.opentagviewer.R; import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; @@ -209,6 +211,159 @@ public void itneverComesBackOnceItHasBeenSeen() { this.theOfferIsShowing()); } + /** + * Showing it is what records it, before anybody has answered. + * + *

The class promises that a dialog dismissed by the activity being torn down still counts + * as the one time. That only holds if the write happens when the dialog goes up - so this + * asserts exactly that, with nothing pressed. + */ + @Test + public void theOfferIsRecordedTheMomentItIsShown() { + this.settingsWhere(settings -> settings.setAnisetteMode(UserSettings.ANISETTE_LOCAL)); + + this.openTheMap(); + Eventually.check(() -> onView(withText(R.string.icloud_offer_title)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + + Eventually.check(() -> assertTrue( + "the offer has to be recorded when it is shown, not when it is answered", + this.theOfferHasBeenMade())); + } + + /** + * A resume while the offer is up must not lose the record of it. + * + *

This is the bug a user hit: the prompt came back days after they had answered it, with + * an account already connected. {@code MapsActivity.onResume} re-reads the settings into the + * field the dialog had marked, so the marked object was replaced by a fresh one still saying + * the offer had never been made - and the answer then saved that. Nothing failed, nothing + * logged, and the prompt returned on every launch. + * + *

A resume between showing and answering is not a contrived sequence: it is what happens + * when somebody glances at another app and comes back, and it is also the ordinary + * onCreate/onResume ordering when the membership lookup answers quickly. + * + *

Confirmed to fail before the fix - the stored flag came back false, and the offer + * appeared again on reopening. + */ + @Test + public void theOfferSurvivesAResumeWhileItIsOnScreen() { + this.settingsWhere(settings -> settings.setAnisetteMode(UserSettings.ANISETTE_LOCAL)); + + this.openTheMap(); + Eventually.check(() -> onView(withText(R.string.icloud_offer_title)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + + // The step that used to swap the settings object out from under the dialog. + this.scenario.moveToState(Lifecycle.State.STARTED); + this.scenario.moveToState(Lifecycle.State.RESUMED); + + Eventually.check(() -> assertTrue( + "a resume while the dialog was up threw away the record that it was offered", + this.theOfferHasBeenMade())); + + this.scenario.close(); + this.openTheMap(); + + this.letTheMapSettle(); + assertFalse("the offer came back after a resume, which is the reported bug", + this.theOfferIsShowing()); + } + + /** + * A connection that exists but cannot be read is a fault, not a fresh install. + * + *

The membership read used to answer "empty" both for somebody who had never joined and + * for somebody whose stored keys could no longer be decrypted - so a device whose secure + * storage had moved on was shown the first-time setup offer, once, and if that was declined + * the app behaved from then on as though iCloud had never been wanted. Nothing said anything + * was wrong; account reads just stopped working. + * + *

Two claims here, and the second is the one with teeth: they are told what happened, and + * their one-and-only first-time offer is not spent on it. Spending it would leave + * somebody who dismissed a message they did not understand with no way back to the feature + * except a Settings item they have no reason to open. + */ + @Test + public void aConnectionWhoseKeyHasGoneAsksThemToReconnect() { + this.settingsWhere(settings -> settings.setAnisetteMode(UserSettings.ANISETTE_LOCAL)); + this.givenAConnection(); + this.andThenItsKeystoreKeyDisappears(); + + this.openTheMap(); + + Eventually.check(() -> onView(withText(R.string.icloud_membership_unreadable_title)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + + assertFalse("the one-time offer must not be spent on a broken connection", + this.theOfferHasBeenMade()); + } + + /** + * And the same situation with the key still present is a bug, so it offers a report. + * + *

The distinction @parawanderer asked for. A key that has gone is somebody's device - an + * OS upgrade, a wiped keystore, a transfer tool that copied app data and could not copy + * keystore keys - and the useful thing to say is "connect it again". A key that is right + * there and still does not open the data is not explainable by any of that, so an + * explanation would be an apology for something the user cannot act on, and the report is + * what is actually worth offering. + */ + @Test + public void aConnectionThatWillNotOpenWithItsOwnKeyOffersABugReport() { + this.settingsWhere(settings -> settings.setAnisetteMode(UserSettings.ANISETTE_LOCAL)); + this.givenAConnection(); + this.butItsStoredBytesAreDamaged(); + + this.openTheMap(); + + Eventually.check(() -> intended(hasComponent(ErrorReportActivity.class.getName()))); + + assertFalse("a fault must not spend the one-time offer either", + this.theOfferHasBeenMade()); + } + + /** A real membership, written through the real writer, so the ciphertext is genuine. */ + private void givenAConnection() { + this.memberships.store(new KeychainMembership( + "{\"peer\":\"invented\"}", "entropy", "PASS-CODE-HERE", "This phone", 1)) + .blockingAwait(); + } + + /** + * Take the keystore key away and leave the data behind. + * + *

Deleted rather than corrupted, because that is the actual shape of the situation: the + * keystore and this app's files have different lifetimes, and it is always the key that + * goes. Done explicitly rather than by writing junk and hoping the alias happens not to + * exist - an earlier test in the run may well have created it, which would make this assert + * the opposite case by accident. + */ + private void andThenItsKeystoreKeyDisappears() { + try { + final java.security.KeyStore keyStore = + java.security.KeyStore.getInstance("AndroidKeyStore"); + keyStore.load(null); + keyStore.deleteEntry( + dev.wander.android.opentagviewer.AppKeyStoreConstants.KEYSTORE_ALIAS_KEYCHAIN); + } catch (final Exception e) { + throw new IllegalStateException("could not take the keystore key away", e); + } + } + + /** Keep the key, ruin the ciphertext: the combination that should not be possible. */ + private void butItsStoredBytesAreDamaged() { + UserAuthDataStore.getInstance(this.context).updateDataAsync(preferences -> { + final androidx.datastore.preferences.core.MutablePreferences mutable = + preferences.toMutablePreferences(); + mutable.set(UserAuthDataStore.KEYCHAIN_MEMBERSHIP, + new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32}); + return io.reactivex.rxjava3.core.Single.just(mutable); + }).blockingGet(); + } + /** * Somebody already reading their account is not asked. * diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 255ad158..31ac771c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,7 +8,45 @@ android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> + + + + + + + + + + + + + + @@ -67,6 +105,32 @@ android:foregroundServiceType="location"> + + + + + + + + + + Not the position. A sighting means the tag is with whoever is holding the phone, so + * writing a position for every one of them records where the user went, not where the tag + * is - and does it while the answer is "still here". {@code NearbyScanService} writes a position + * at the two moments that carry information instead: when a tag turns up, and when it stops + * being heard. + * + *

Public rather than package-private since {@code NearbyScanService} joined the two screens + * as a caller. The point of the class is that there is exactly one of these, and a service in + * another package needing its own copy of the policy would be the failure it exists to prevent. + * + *

One class because the policy used to live in four hand-copied methods - a + * {@code correctAlignmentFromSighting} and a {@code keepWhatTheSightingProved} in each of + * {@code MapsActivity} and {@code DeviceInfoActivity} - and a change to how sightings are + * persisted already had to be applied to all four in lockstep once. Missing one would have + * silently diverged alignment self-correction between the map and the device screen. + * + *

Persists only - deliberately no reread of the screen's model and no watch restart. + * A first attempt at that reset every card's already-computed geocoding on the map on every + * correction, with nothing to refill it. The running session keeps matching against the + * alignment it started with until the next load or periodic fetch picks the correction up; + * a narrower per-beacon patch is still open. + * + *

Failure is logged and swallowed: a sighting that cannot be persisted costs the next scan + * a wider search, nothing else, and it must never turn a successful ring into an error. + */ +public final class AccessorySightingPersister { + private static final String TAG = AccessorySightingPersister.class.getSimpleName(); + + private final BeaconRepository beaconRepo; + + /** + * Where the phone is, or null for a caller that records position some other way. + * + *

The screens pass one, {@code NearbyScanService} passes null. Not an oversight: + * the service already writes a position on the two edges it cares about, arriving and going + * quiet, precisely so that it is not reading a location on every advertisement while nobody + * is looking. A screen is the opposite situation - somebody has the app open and is watching + * a tag be found - and the fix is cheap there because the app is in the foreground, which is + * the only state its location permission covers anyway. + * + *

Without this the map kept showing the last thing Apple's network said, while the same + * screen was reporting the tag as audible right now. Two answers to "where is it", and the + * worse one was the one being drawn. + */ + @Nullable + private final PhoneLocation phoneLocation; + + /** + * Told when a position was actually written, so a screen can show it without waiting. + * + *

Because a row nobody redraws is a row nobody sees. The map draws from what the + * last network fetch handed it, so a position written between fetches sat in the database + * being correct and invisible, and the screen went on showing Apple's older answer for the + * same tag. Fired only for a write that happened - a sighting dropped by the 25 metre rule + * changes nothing on screen and is not worth a redraw. + * + *

Called on the Rx io thread. A listener that touches views has to get itself onto the + * main thread. + */ + public interface LocalPositionListener { + void onWritten(String beaconId, BeaconLocationReport report); + } + + @Nullable + private final LocalPositionListener localPositionListener; + + public AccessorySightingPersister(final BeaconRepository beaconRepo) { + this(beaconRepo, null, null); + } + + public AccessorySightingPersister(final BeaconRepository beaconRepo, + @Nullable final PhoneLocation phoneLocation) { + this(beaconRepo, phoneLocation, null); + } + + public AccessorySightingPersister(final BeaconRepository beaconRepo, + @Nullable final PhoneLocation phoneLocation, + @Nullable final LocalPositionListener localPositionListener) { + this.beaconRepo = beaconRepo; + this.phoneLocation = phoneLocation; + this.localPositionListener = localPositionListener; + } + + /** + * A passive sighting from a {@code NearbyTagWatcher} - shaped to be used directly as its + * {@code SightingListener}. + * + *

Two writes from the one advertisement, and they answer different questions. The address + * says which key the tag is broadcasting, which corrects alignment; the status byte says what + * its battery was, which is worth keeping long after the tag has gone quiet, because for a + * user with no Apple device nothing else will ever report it - see + * {@code BeaconRepository#storeLastSighting}. + */ + public void onSighting(final NearbyTagSighting sighting, final String mac) { + this.maybeCorrectAlignment(sighting, mac); + this.persistLastSighting(sighting); + this.maybeRecordWhereItWasHeard(sighting); + } + + /** + * Writes where this phone was when it heard the tag, if that is worth keeping. + * + *

Unthrottled here on purpose. Two things already limit it: the fix comes from a + * cache that only asks the platform once a minute, and + * {@code BeaconRepository#recordLocalSighting} drops anything that has not moved 25 metres or + * waited a quarter of an hour. Adding a third rule here would only make the real one harder + * to find. + * + *

Silent when there is no fix. A phone indoors with no recent location has nothing to say + * about where the tag is, and a report at a guessed position is worse than no report. + */ + private void maybeRecordWhereItWasHeard(final NearbyTagSighting sighting) { + if (this.phoneLocation == null) { + return; + } + + final PhoneLocation.Fix fix = this.phoneLocation.lastKnown(); + if (fix == null) { + return; + } + + this.beaconRepo.recordLocalSighting( + sighting.getBeaconId(), fix.getLatitude(), fix.getLongitude(), + Math.round(fix.getAccuracyMetres()), sighting.getStatusByte(), + sighting.getSeenAtMs()) + .subscribe(written -> { + if (written.isPresent() && this.localPositionListener != null) { + this.localPositionListener.onWritten( + sighting.getBeaconId(), written.get()); + } + }, error -> Log.w(TAG, + "Could not record where beaconId=" + sighting.getBeaconId() + + " was heard", error)); + } + + /** + * How often one tag's alignment is worth re-deriving. + * + *

The keys only move every fifteen minutes, so correcting faster than that buys + * nothing - the second call within one rotation re-derives the same answer and writes + * nothing. It is also the most expensive thing on this path by a wide margin: the candidate + * window spans 48 hours, which is around 1150 key derivations, measured at 1.15s on desktop + * and several times that under Chaquopy. + * + *

Running it on the sighting callback's own once-a-minute cadence put the app at 135% CPU + * with two tags in range, continuously, and Android eventually killed it for not answering + * input. Battery and position stay on the faster cadence: they cost a row each. + */ + private static final long ALIGNMENT_INTERVAL_MS = TimeUnit.MINUTES.toMillis(15); + + /** When each tag's alignment was last re-derived. Written from the Rx io scheduler. */ + private final Map lastAlignmentMs = new ConcurrentHashMap<>(); + + private void maybeCorrectAlignment(final NearbyTagSighting sighting, final String mac) { + final Long last = this.lastAlignmentMs.get(sighting.getBeaconId()); + if (last != null && sighting.getSeenAtMs() - last < ALIGNMENT_INTERVAL_MS) { + return; + } + this.lastAlignmentMs.put(sighting.getBeaconId(), sighting.getSeenAtMs()); + + this.persist(sighting.getBeaconId(), mac, sighting.getSeenAtMs(), + sighting.getKeyIndex()); + } + + /** + * A sighting proven by a ring attempt: the scan matched, whatever the GATT exchange did + * afterwards. Ignores progress updates and outcomes where nothing was found. + */ + void keepWhatTheSightingProved(final String beaconId, final BleSoundTriggerUpdate update) { + if (update.getPhase() != BleSoundTriggerPhase.DONE + || update.getResult().getMatchedMac() == null) { + return; + } + // No hint from the ring path: BleSoundTriggerResult carries the address only, on + // purpose, and this runs once per button press rather than on a scan cadence. + this.persist(beaconId, update.getResult().getMatchedMac(), System.currentTimeMillis(), + null); + } + + private void persist(final String beaconId, final String mac, final long seenAtMs, + final Integer hintIndex) { + this.beaconRepo.recordAccessorySighting(beaconId, mac, seenAtMs, hintIndex) + .subscribe(() -> { }, error -> Log.w(TAG, + "Failed to persist a sighting for beaconId=" + beaconId, error)); + } + + private void persistLastSighting(final NearbyTagSighting sighting) { + this.beaconRepo.storeLastSighting( + sighting.getBeaconId(), + sighting.getBatteryLevel(), + sighting.getStatusByte(), + sighting.getSeenAtMs()) + .subscribe(() -> { }, error -> Log.w(TAG, + "Failed to persist a sighting reading for beaconId=" + + sighting.getBeaconId(), error)); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java index b02f4a69..ed59f270 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java @@ -32,6 +32,8 @@ import androidx.appcompat.app.AppCompatDelegate; import androidx.core.os.LocaleListCompat; import androidx.databinding.DataBindingUtil; + +import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; import androidx.lifecycle.ViewModelProvider; import com.chaquo.python.PyObject; @@ -232,6 +234,9 @@ public void handleOnBackPressed() { this.twoFactorEntryManager = new Apple2FACodeInputManager(this, this::on2FAAuthCodeFilled); this.binding = DataBindingUtil.setContentView(this, R.layout.activity_apple_login); + // This screen had neither inset applied - so its buttons sat under the navigation bar + // and its heading under the status bar, on the very first screen anybody sees. + WindowPaddingUtil.insetForSystemBars(this.binding.getRoot()); if (this.getSupportActionBar() != null) { this.getSupportActionBar().hide(); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java index de4b5cde..af1545c5 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java @@ -4,11 +4,13 @@ import static android.view.View.VISIBLE; import static android.view.View.inflate; import static android.widget.Toast.LENGTH_LONG; +import static android.widget.Toast.LENGTH_SHORT; import static dev.wander.android.opentagviewer.util.android.TextChangedWatcherFactory.justWatchOnChanged; import android.content.ClipData; import android.content.ClipboardManager; +import android.bluetooth.le.ScanSettings; import android.content.Context; import android.content.Intent; import android.net.Uri; @@ -31,6 +33,7 @@ import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.annotation.Nullable; +import androidx.core.app.ActivityCompat; import androidx.appcompat.content.res.AppCompatResources; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.databinding.DataBindingUtil; @@ -38,6 +41,7 @@ import androidx.emoji2.emojipicker.EmojiViewItem; import com.google.android.material.button.MaterialButton; +import com.google.android.material.materialswitch.MaterialSwitch; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.textfield.TextInputEditText; @@ -45,9 +49,19 @@ import java.util.Date; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.Optional; - +import java.util.concurrent.TimeUnit; + +import dev.wander.android.opentagviewer.ble.BlePermissions; +import dev.wander.android.opentagviewer.ble.BleSoundTriggerPhase; +import dev.wander.android.opentagviewer.ble.BleSoundTriggerResult; +import dev.wander.android.opentagviewer.ble.BleSoundTriggerUpdate; +import dev.wander.android.opentagviewer.ble.NearbyTagLabel; +import dev.wander.android.opentagviewer.ble.NearbyTagSighting; +import dev.wander.android.opentagviewer.ble.NearbyTagSightings; +import dev.wander.android.opentagviewer.ble.NearbyTagWatcher; import dev.wander.android.opentagviewer.data.model.BeaconInformation; import dev.wander.android.opentagviewer.data.model.UserMapCameraPosition; import dev.wander.android.opentagviewer.databinding.ActivityDeviceInfoBinding; @@ -59,15 +73,20 @@ import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; import dev.wander.android.opentagviewer.db.repo.UserSettingsRepository; import dev.wander.android.opentagviewer.db.repo.model.BeaconData; +import dev.wander.android.opentagviewer.db.repo.model.LastSightingData; import dev.wander.android.opentagviewer.db.repo.model.UserSettings; import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; import dev.wander.android.opentagviewer.db.room.entity.Import; import dev.wander.android.opentagviewer.db.room.entity.UserBeaconOptions; import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; +import dev.wander.android.opentagviewer.util.android.CachedPhoneLocation; +import dev.wander.android.opentagviewer.util.android.FusedPhoneLocation; import dev.wander.android.opentagviewer.util.android.PropertiesUtil; import dev.wander.android.opentagviewer.util.android.WebLink; +import dev.wander.android.opentagviewer.util.parse.AccessoryAlignment; import dev.wander.android.opentagviewer.util.parse.BatteryLevelDescription; import dev.wander.android.opentagviewer.util.parse.BeaconDataParser; +import dev.wander.android.opentagviewer.util.parse.LocationReportFields; import dev.wander.android.opentagviewer.util.rx.WideScanBackoff; import dev.wander.android.opentagviewer.python.AppDependencies; import dev.wander.android.opentagviewer.ui.BeaconIcon; @@ -82,9 +101,21 @@ import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.annotations.NonNull; -public class DeviceInfoActivity extends AppCompatActivity { +public class DeviceInfoActivity extends AppCompatActivity + implements ActivityCompat.OnRequestPermissionsResultCallback { private static final String TAG = DeviceInfoActivity.class.getSimpleName(); + private static final int PERMISSION_REQUEST_PLAY_SOUND_NEARBY = 1001; + + /** + * How often the "Last seen" row is redrawn while the tag is quiet. + * + *

Matched to the coarsest unit {@code DateUtils} is asked for here, a minute: redrawing + * faster changes nothing on screen, and redrawing slower would leave the row a minute behind + * for someone watching it. + */ + private static final long LAST_SEEN_REFRESH_MS = TimeUnit.MINUTES.toMillis(1); + private static final double DEFAULT_LONGITUDE = 0d; private static final double DEFAULT_LATITUDE = 0d; private static final float DEFAULT_ZOOM = 16.0f; @@ -123,6 +154,52 @@ public class DeviceInfoActivity extends AppCompatActivity { /** The in-flight write to the account, so leaving the screen does not land on dead views. */ private Disposable accountRename; + /** The in-flight BLE scan/GATT trigger, so leaving the screen stops it rather than + * leaving a scan running or a result landing on dead views. */ + private Disposable playSoundNearby; + + /** Reused so each new status (searching/connecting/sending/result) replaces the last one + * on screen instead of queuing behind it - see {@link #showPlaySoundStatus}. */ + private Toast playSoundStatusToast; + + /** + * Listens for this one tag while the screen is open, to show a battery reading taken off the + * tag itself rather than out of the iCloud record. + * + *

Its own scan rather than one handed over from the map. Opening this screen + * pauses {@code MapsActivity}, which stops that scan, so a sighting passed across would be + * stale on arrival - and this screen can also be reached without the map having run at all. + */ + private Disposable nearbyWatchDisposable; + + /** + * Hides the live battery row once its last sighting is too old to stand behind - reset by + * every new sighting, so the row only ages out when the tag has genuinely gone quiet. + * + *

Without this the row never aged at all: once a tag had been heard, "read from the tag + * just now" stayed on screen for hours after the tag left earshot - exactly the staleness + * the row exists to be free of. Same clock as the map card's badge: + * {@link NearbyTagSightings#FRESH_FOR_MS}. + */ + private Disposable liveBatteryExpiry; + + /** The pending retry after the scan died mid-session - see {@link #onNearbyWatchEnded}. */ + private Disposable nearbyWatchRetryDisposable; + + /** The in-flight read of the stored sighting - see {@link #showWhatWasHeardOverBluetooth}. */ + private Disposable lastSightingLookup; + + /** + * Redraws the "Last seen" row while the tag is quiet, so its age keeps up with the clock. + * + *

Only runs in that state. While the tag is audible the row reads "just now" and every + * advertisement rewrites it anyway; once there is nothing stored the row is not on screen. + */ + private Disposable lastSeenTicker; + + /** The one place a Bluetooth sighting is persisted - see {@link AccessorySightingPersister}. */ + private AccessorySightingPersister sightingPersister; + private boolean hasNameChanges = false; @Override @@ -146,6 +223,8 @@ protected void onCreate(Bundle savedInstanceState) { this.beaconRepo = new BeaconRepository( OpenTagViewerDatabase.getInstance(getApplicationContext())); + this.sightingPersister = new AccessorySightingPersister(this.beaconRepo, + new CachedPhoneLocation(new FusedPhoneLocation(this.getApplicationContext()))); this.beaconData = this.beaconRepo.getById(this.beaconId).blockingFirst(); this.beaconInformation = BeaconDataParser.parse(List.of(this.beaconData)).get(0); @@ -160,7 +239,7 @@ protected void onCreate(Bundle savedInstanceState) { : this.beaconRepo.getImportById(importId).blockingFirst().orElse(null); binding = DataBindingUtil.setContentView(this, R.layout.activity_device_info); - WindowPaddingUtil.insertUITopPadding(binding.getRoot()); + WindowPaddingUtil.insetForSystemBars(binding.getRoot()); binding.setHandleClickBack(this::handleEndActivity); binding.setHandleClickMenu(this::handleClickMenu); @@ -286,7 +365,8 @@ protected void onCreate(Bundle savedInstanceState) { R.id.settings_debug_naming_record_pairing_date, R.id.settings_debug_naming_record_product_id, R.id.settings_debug_naming_record_system_version, - R.id.settings_debug_naming_record_vendor_id + R.id.settings_debug_naming_record_vendor_id, + R.id.settings_debug_key_alignment ); ClipboardManager clipboard = (ClipboardManager) @@ -579,8 +659,290 @@ private void hideEmojiMenu() { .start(); } + @Override + protected void onResume() { + super.onResume(); + this.startWatchingForThisTag(); + this.showWhatWasHeardOverBluetooth(); + this.showLeftBehindSwitch(); + } + + @Override + protected void onPause() { + super.onPause(); + this.stopWatchingForThisTag(); + if (this.leftBehindLookup != null && !this.leftBehindLookup.isDisposed()) { + this.leftBehindLookup.dispose(); + } + } + + /** + * Listen for this tag while the screen is open, to fill in the live battery row. + * + *

Silent when it cannot run - no permission, Bluetooth off, or an accessory JSON that has + * not been backfilled all simply leave the row hidden, which is what it looks like when the + * tag is out of earshot anyway. + */ + private void startWatchingForThisTag() { + this.stopWatchingForThisTag(); + + final String accessoryJson = this.beaconData.getOwnedBeaconInfo().accessoryJson; + if (accessoryJson == null || accessoryJson.isEmpty()) { + return; + } + + this.nearbyWatchDisposable = new NearbyTagWatcher( + AppDependencies.accessoryMacResolver(), this.sightingPersister::onSighting, + ScanSettings.SCAN_MODE_LOW_LATENCY) + .watch(this.getApplicationContext(), Map.of(this.beaconId, accessoryJson)) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::showLiveSighting, + error -> Log.w(TAG, "Nearby watch ended for beaconId=" + this.beaconId, error), + this::onNearbyWatchEnded); + } + + /** + * The scan died mid-session (Bluetooth off, or the platform refused the scan) rather than + * being stopped - disposal skips onComplete. Retried every 30 seconds so the live battery + * row comes back on its own when the radio does; see {@code MapsActivity}'s twin for the + * budget reasoning. + */ + private void onNearbyWatchEnded() { + Log.i(TAG, "Nearby watch ended mid-session for beaconId=" + this.beaconId + + "; retrying in 30s"); + this.nearbyWatchRetryDisposable = Observable + .timer(30, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) + .subscribe(tick -> this.startWatchingForThisTag()); + } + + private void stopWatchingForThisTag() { + if (this.nearbyWatchDisposable != null && !this.nearbyWatchDisposable.isDisposed()) { + this.nearbyWatchDisposable.dispose(); + } + this.nearbyWatchDisposable = null; + if (this.liveBatteryExpiry != null && !this.liveBatteryExpiry.isDisposed()) { + this.liveBatteryExpiry.dispose(); + } + this.liveBatteryExpiry = null; + if (this.lastSightingLookup != null && !this.lastSightingLookup.isDisposed()) { + this.lastSightingLookup.dispose(); + } + this.lastSightingLookup = null; + if (this.lastSeenTicker != null && !this.lastSeenTicker.isDisposed()) { + this.lastSeenTicker.dispose(); + } + this.lastSeenTicker = null; + if (this.nearbyWatchRetryDisposable != null + && !this.nearbyWatchRetryDisposable.isDisposed()) { + this.nearbyWatchRetryDisposable.dispose(); + } + this.nearbyWatchRetryDisposable = null; + } + + /** + * Shows what the tag is saying right now: heard just now, this strong, this much battery. + * + *

Never falls back to the iCloud value for the battery row, and never merges with it. The + * whole reason this section is outside the record's own fields is that it says where its + * numbers came from; quietly filling one of them from the other source would defeat that. + * The debug row keeps the record's value, with its caveat. + */ + private void showLiveSighting(final NearbyTagSighting sighting) { + // A stored reading on its way back from the database would land on top of this one and + // relabel a tag we can hear right now as last heard some minutes ago. The live value + // always wins, so the read that was going to contradict it is dropped rather than raced. + if (this.lastSightingLookup != null && !this.lastSightingLookup.isDisposed()) { + this.lastSightingLookup.dispose(); + } + if (this.lastSeenTicker != null && !this.lastSeenTicker.isDisposed()) { + this.lastSeenTicker.dispose(); + } + + this.binding.setBleLastSeen(this.getString(R.string.seen_just_now)); + this.binding.setBleSignalStrength(NearbyTagLabel.signalStrengthBars(sighting.getRssi())); + this.binding.setBleBatteryLevel( + this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel()))); + this.showStatusByteForDebugging(sighting.getStatusByte()); + + this.showBluetoothSection(true); + + // Every sighting restarts the expiry, so the section only stops claiming to be current + // once the tag has been quiet for the whole window - see the field doc. + if (this.liveBatteryExpiry != null && !this.liveBatteryExpiry.isDisposed()) { + this.liveBatteryExpiry.dispose(); + } + this.liveBatteryExpiry = Observable + .timer(NearbyTagSightings.FRESH_FOR_MS, TimeUnit.MILLISECONDS, + AndroidSchedulers.mainThread()) + .subscribe(tick -> this.showWhatWasHeardOverBluetooth()); + } + + /** + * Falls back to what the tag last said, with its age, once it has gone quiet. + * + *

What the section says when the tag is out of earshot. The live reading expires + * because a tag carried away stops being here - see {@link NearbyTagSightings} - but the + * battery it reported on the way out is still the best answer anybody has, and for a user + * with no Apple device it is the only one: the record's own field is updated by Apple's + * devices and stays at "not yet reported" forever otherwise. So the claim is weakened rather + * than withdrawn, from "this is the level" to "this is the level when it was last heard". + * + *

The signal row goes, the battery row stays. That split is the point of splitting + * them. A battery level from an hour ago is still roughly the battery level; a signal + * strength from an hour ago describes a distance to a tag that is no longer there, and there + * is no wording that makes it useful. So it is withdrawn rather than dated. + * + *

The age on "Last seen" is not decoration either. Without it this is the same trap as the + * debug panel's iCloud value: a battery word with no date reads as current, and "full" from a + * tag last heard in March is worse than an empty row. + * + *

Hides the whole section when there is nothing stored, which is a tag this phone has + * never heard - a new install, or one whose tags have only ever been seen over the network. + */ + private void showWhatWasHeardOverBluetooth() { + if (this.lastSightingLookup != null && !this.lastSightingLookup.isDisposed()) { + this.lastSightingLookup.dispose(); + } + + this.lastSightingLookup = this.beaconRepo.getLastSighting(this.beaconId) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(stored -> { + if (stored.isEmpty()) { + this.showBluetoothSection(false); + return; + } + + final LastSightingData sighting = stored.get(); + this.binding.setBleBatteryLevel(this.getString( + NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel()))); + this.showStatusByteForDebugging(sighting.getStatusByte()); + this.showAgeOfLastSighting(sighting.getHeardAtMs()); + this.showBluetoothSection(true, false); + + // "3 minutes ago" is only true for a minute. Nothing else on this screen + // redraws while it sits open, so without a tick the row would freeze at + // whatever it said when the tag went quiet and keep saying it for as long as + // somebody watched - which is precisely the staleness this row exists to + // report rather than commit. + this.lastSeenTicker = Observable + .interval(LAST_SEEN_REFRESH_MS, LAST_SEEN_REFRESH_MS, + TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) + .subscribe(tick -> this.showAgeOfLastSighting( + sighting.getHeardAtMs())); + }, error -> Log.w(TAG, "Could not read the last sighting for beaconId=" + + this.beaconId, error)); + } + + /** + * Puts the raw status byte the battery reading came out of into the debug panel. + * + *

To be measured against, not read as a battery level. The section above decodes + * bits 6-7 of this byte per Apple's Table 5-5, which is right for an MFi accessory and wrong + * for an AirTag: {@link LocationReportFields} records one advertising {@code 0x90}, which + * fails that table's marker and reserved bits, and notes that decoding it anyway reads "low" + * for a tag whose own record says full. Every accessory this feature was built against is + * third-party, so the reading has never been checked against hardware that does not follow + * the specification. + * + *

Rendered by {@link LocationReportFields#status}, deliberately: it is the same rendering + * a network report's copy of this byte gets, so the two can be compared directly, and it + * appends a Table 5-5 reading only to a byte that actually conforms - which is the question + * this row exists to answer. + */ + private void showStatusByteForDebugging(final int statusByte) { + this.binding.setBleStatusByte(LocationReportFields.status(statusByte)); + this.findViewById(R.id.settings_debug_ble_status_byte).setVisibility(VISIBLE); + } + + /** + * Shows and wires the per-tag left-behind switch. + * + *

Undecided reads as off, so a tag nobody has answered for stays silent - see + * {@code UserBeaconOptions.alertOnSeparation}. The switch is only revealed once the answer + * has been read, so it cannot flick from a default to the stored value in front of somebody. + */ + private void showLeftBehindSwitch() { + if (this.leftBehindLookup != null && !this.leftBehindLookup.isDisposed()) { + this.leftBehindLookup.dispose(); + } + + this.leftBehindLookup = this.beaconRepo.getAlertOnSeparation(this.beaconId) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(warn -> { + this.binding.setWarnIfLeftBehind(warn); + + final MaterialSwitch toggle = this.findViewById(R.id.device_warn_left_behind); + toggle.setChecked(warn); + toggle.setOnCheckedChangeListener((button, isChecked) -> + this.beaconRepo.storeAlertOnSeparation(this.beaconId, isChecked) + .subscribe(() -> Log.i(TAG, "Left-behind alerts for beaconId=" + + this.beaconId + " are now " + + (isChecked ? "on" : "off")), + error -> Log.w(TAG, + "Could not store the left-behind choice", + error))); + + this.findViewById(R.id.device_warn_left_behind_row).setVisibility(VISIBLE); + this.findViewById(R.id.device_warn_left_behind_explainer) + .setVisibility(VISIBLE); + }, error -> Log.w(TAG, "Could not read the left-behind choice", error)); + } + + /** The in-flight read of the per-tag left-behind choice. */ + private Disposable leftBehindLookup; + + /** + * Writes the "Last seen" row, e.g. "3 minutes ago", from a wall-clock timestamp. + * + *

Under a minute it says "just now" rather than what the formatter returns, which + * is "0 minutes ago". That is the reading for the first half-minute after a tag goes quiet - + * the live window is thirty seconds - so it is not a rare corner, it is what everybody sees + * on the way from hearing the tag to not hearing it. "0 minutes ago" is also not really + * English. Asking the formatter for second resolution instead would say "43 seconds ago", + * which is a precision this row cannot keep: it redraws once a minute. + * + *

The same words as the live row, and that is honest - the tag really was heard just now. + * What separates the two states on screen is the signal row, which is there only while the + * reading is live. + */ + private void showAgeOfLastSighting(final long heardAtMs) { + final long ageMs = System.currentTimeMillis() - heardAtMs; + + this.binding.setBleLastSeen(ageMs < DateUtils.MINUTE_IN_MILLIS + ? this.getString(R.string.seen_just_now) + : DateUtils.getRelativeTimeSpanString( + heardAtMs, System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS).toString()); + } + + /** The section with every row, for a tag being heard right now. */ + private void showBluetoothSection(final boolean visible) { + this.showBluetoothSection(visible, visible); + } + + /** + * Shows or hides the "Over Bluetooth" section - its divider, its heading and its rows + * together, so it never appears as a heading with nothing under it. + * + * @param signalToo whether the signal row is among them. False once the tag has gone quiet: + * see {@link #showWhatWasHeardOverBluetooth} for why that one row is + * withdrawn while the others are merely dated. + */ + private void showBluetoothSection(final boolean visible, final boolean signalToo) { + final int visibility = visible ? VISIBLE : GONE; + + this.findViewById(R.id.device_ble_divider).setVisibility(visibility); + this.findViewById(R.id.device_ble_header).setVisibility(visibility); + this.findViewById(R.id.device_settings_ble_last_seen).setVisibility(visibility); + this.findViewById(R.id.device_settings_ble_battery).setVisibility(visibility); + this.findViewById(R.id.device_settings_ble_signal) + .setVisibility(signalToo ? VISIBLE : GONE); + } + @Override protected void onDestroy() { + this.stopWatchingForThisTag(); if (this.hardwareLookup != null && !this.hardwareLookup.isDisposed()) { this.hardwareLookup.dispose(); } @@ -590,9 +952,142 @@ protected void onDestroy() { if (this.accountRename != null && !this.accountRename.isDisposed()) { this.accountRename.dispose(); } + // Here disposing does cancel the underlying work - see BleGattSoundTrigger.trigger's + // cancellable, which closes the GATT connection rather than leaving it dangling. + if (this.playSoundNearby != null && !this.playSoundNearby.isDisposed()) { + this.playSoundNearby.dispose(); + } super.onDestroy(); } + /** + * Ask to play this accessory's sound directly over Bluetooth, without going through Apple's + * Find My network - see {@code dev.wander.android.opentagviewer.ble}. Only reachable while + * the accessory is close enough to answer a BLE scan, unlike the network-based search this + * screen otherwise relies on. + */ + private void onClickPlaySoundNearby() { + if (!BlePermissions.granted(this)) { + Log.d(TAG, "Requesting BLE permission(s) before playing sound nearby"); + ActivityCompat.requestPermissions( + this, BlePermissions.required(), PERMISSION_REQUEST_PLAY_SOUND_NEARBY); + return; + } + this.startPlaySoundNearby(); + } + + @Override + public void onRequestPermissionsResult( + final int requestCode, @androidx.annotation.NonNull final String[] permissions, + @androidx.annotation.NonNull final int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode != PERMISSION_REQUEST_PLAY_SOUND_NEARBY) return; + + // Re-checked against the same BlePermissions.granted this action gates on elsewhere, + // rather than reading grantResults directly - one place decides what "enough" means, + // matching the reasoning in BlePermissions' own class doc. + if (BlePermissions.granted(this)) { + Log.i(TAG, "BLE permission granted; playing sound nearby for beaconId=" + this.beaconId); + this.startPlaySoundNearby(); + } else { + Log.i(TAG, "BLE permission refused; not playing sound nearby for beaconId=" + this.beaconId); + Toast.makeText(this, R.string.play_sound_permission_denied, LENGTH_LONG).show(); + } + } + + private void startPlaySoundNearby() { + final String accessoryJson = this.beaconData.getOwnedBeaconInfo().accessoryJson; + + this.showPlaySoundStatus(R.string.play_sound_searching, LENGTH_SHORT); + + if (this.playSoundNearby != null && !this.playSoundNearby.isDisposed()) { + this.playSoundNearby.dispose(); + } + + this.playSoundNearby = AppDependencies.accessorySoundTrigger() + .playSound(this.getApplicationContext(), accessoryJson) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::handlePlaySoundUpdate, + error -> { + // AccessorySoundTrigger's contract is to never error a failure onto + // this path - see its interface doc - so reaching here means a bug + // in that contract, not an ordinary "not found" or "no permission". + Log.e(TAG, "Unexpected error playing sound for beaconId=" + + this.beaconId, error); + this.showPlaySoundStatus(R.string.play_sound_failed, LENGTH_LONG); + }); + } + + /** + * One item of the play-sound stream: a progress phase (shown and replaced, see + * {@link #showPlaySoundStatus}) or the terminal outcome. + */ + private void handlePlaySoundUpdate(final BleSoundTriggerUpdate update) { + this.sightingPersister.keepWhatTheSightingProved(this.beaconId, update); + + if (update.getPhase() != BleSoundTriggerPhase.DONE) { + this.showPlaySoundStatus(phaseMessageRes(update.getPhase()), LENGTH_SHORT); + return; + } + this.showPlaySoundResult(update.getResult()); + } + + private static int phaseMessageRes(final BleSoundTriggerPhase phase) { + switch (phase) { + case CONNECTING: + return R.string.play_sound_connecting; + case TRIGGERING: + return R.string.play_sound_sending; + case SCANNING: + default: + return R.string.play_sound_searching; + } + } + + private void showPlaySoundResult(final BleSoundTriggerResult result) { + Log.d(TAG, "Play sound result for beaconId=" + this.beaconId + ": " + result.getStatus() + + (result.getMessage() == null ? "" : " (" + result.getMessage() + ")")); + + final int messageRes; + switch (result.getStatus()) { + case SUCCESS: + messageRes = R.string.play_sound_success; + break; + case NOT_NEARBY: + messageRes = R.string.play_sound_not_nearby; + break; + case NO_SOUND_SERVICE: + messageRes = R.string.play_sound_no_sound_service; + break; + case NO_CANDIDATE_MACS: + messageRes = R.string.play_sound_no_candidate_macs; + break; + case MISSING_PERMISSION: + messageRes = R.string.play_sound_permission_denied; + break; + case FAILED: + default: + messageRes = R.string.play_sound_failed; + break; + } + this.showPlaySoundStatus(messageRes, LENGTH_LONG); + } + + /** + * Cancels whichever status toast is on screen and shows the next one immediately, rather + * than queuing behind it. Plain sequential {@code Toast.makeText(...).show()} calls queue + * with a fixed display duration each, so "searching" would sit on screen for its whole + * duration even after "connecting" was already true - reading as stuck, not as progress. + */ + private void showPlaySoundStatus(final int messageRes, final int duration) { + if (this.playSoundStatusToast != null) { + this.playSoundStatusToast.cancel(); + } + this.playSoundStatusToast = Toast.makeText(this, messageRes, duration); + this.playSoundStatusToast.show(); + } + /** * The best description available without asking Python. * @@ -726,6 +1221,8 @@ private void handleClickMenu() { if (menuItem.getItemId() == R.id.device_location_history) { this.redirectToDeviceHistory(); + } else if (menuItem.getItemId() == R.id.device_play_sound_nearby) { + this.onClickPlaySoundNearby(); } else if (menuItem.getItemId() == R.id.device_delete) { this.onClickDeviceDelete(); } @@ -793,6 +1290,29 @@ private void describeHowItIsBeingLookedFor(final SimpleDateFormat timestamps) { : timestamps.format(new Date(newest))); this.binding.setBackoffState(this.describeBackoff(timestamps)); + this.binding.setKeyAlignment(this.describeKeyAlignment(timestamps)); + } + + /** + * Where the next key search starts, and how far the last one got. + * + *

Read from the accessory state, not from the export's record. The record is written + * once at import; this advances on every fetch, which is what makes it worth showing. A tag + * whose row says "None stored" is one whose next fetch searches from the pairing date - tens + * of thousands of keys for an old tag - and that is the single most useful thing to know when + * somebody reports a fetch that takes minutes or comes back empty. + */ + private String describeKeyAlignment(final SimpleDateFormat timestamps) { + final String accessoryJson = this.beaconInformation.getOwnedBeaconAccessoryJson(); + final Integer index = AccessoryAlignment.alignedIndex(accessoryJson); + final Long alignedAt = AccessoryAlignment.alignedAtMillis(accessoryJson); + + if (index == null || alignedAt == null) { + return this.getString(R.string.debug_key_alignment_none); + } + + return this.getString( + R.string.debug_key_alignment_value, index, timestamps.format(new Date(alignedAt))); } private String describeBackoff(final SimpleDateFormat timestamps) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java index bf192976..55c895f4 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java @@ -175,7 +175,11 @@ protected void onCreate(final Bundle savedInstanceState) { this.membershipRepo = new KeychainMembershipRepository( UserAuthDataStore.getInstance(this.getApplicationContext()), new AppCryptographyUtil()); - WindowPaddingUtil.insertUITopPadding(this.findViewById(R.id.icloud_scroll)); + // **The root, not the scroll area.** The buttons on this screen - back, and the primary + // one that says Unlock - sit *outside* icloud_scroll, anchored to the bottom of the + // activity, so padding the scroll view moved the text and left them exactly where they + // were: under the navigation bar. That is the screenshot in the bug report. + WindowPaddingUtil.insetForSystemBars(this.findViewById(R.id.icloud_root)); if (this.getSupportActionBar() != null) { this.getSupportActionBar().hide(); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java index d8f6ecd8..62c027e4 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java @@ -180,7 +180,7 @@ protected void onCreate(Bundle savedInstanceState) { .blockingFirst(); ActivityHistoryViewBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_history_view); - WindowPaddingUtil.insertUITopPadding(binding.getRoot()); + WindowPaddingUtil.insetForSystemBars(binding.getRoot()); binding.setHandleClickBack(this::finish); binding.setPageTitle(this.getCurrentBeaconName()); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java index 3b6e0b07..4e2e2886 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java @@ -44,7 +44,7 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ActivityInformationBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_information); - WindowPaddingUtil.insertUITopPadding(binding.getRoot()); + WindowPaddingUtil.insetForSystemBars(binding.getRoot()); binding.setHandleClickBack(this::finish); if (this.getSupportActionBar() != null) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java index a71f1a0f..f2b59a2d 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -19,9 +19,12 @@ import androidx.core.view.WindowCompat; +import android.bluetooth.le.ScanSettings; import android.content.Intent; import android.content.pm.ApplicationInfo; +import android.app.Dialog; import android.content.pm.PackageManager; +import android.content.res.ColorStateList; import android.location.Address; import android.location.Geocoder; import android.net.Uri; @@ -62,6 +65,7 @@ import dev.wander.android.opentagviewer.ui.maps.MapPolyline; import dev.wander.android.opentagviewer.ui.maps.MarkerPalette; import com.google.android.libraries.places.api.Places; +import com.google.android.material.color.MaterialColors; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.io.BufferedWriter; @@ -80,6 +84,7 @@ import java.util.Objects; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import dev.wander.android.opentagviewer.data.model.BeaconInformation; @@ -102,6 +107,7 @@ import dev.wander.android.opentagviewer.python.AccessoryRequest; import dev.wander.android.opentagviewer.python.icloud.ICloudFailures; import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.PythonDiagnostics; import dev.wander.android.opentagviewer.python.LogRedactor; import dev.wander.android.opentagviewer.ui.BeaconIcon; import dev.wander.android.opentagviewer.python.PythonAppleService; @@ -110,6 +116,8 @@ import dev.wander.android.opentagviewer.db.repo.UserDataRepository; import dev.wander.android.opentagviewer.ui.maps.TagCardHelper; import dev.wander.android.opentagviewer.ui.maps.TagListSwiperHelper; +import dev.wander.android.opentagviewer.util.android.CachedPhoneLocation; +import dev.wander.android.opentagviewer.util.android.FusedPhoneLocation; import dev.wander.android.opentagviewer.util.LogCollectorUtil; import dev.wander.android.opentagviewer.util.MapUtils; import dev.wander.android.opentagviewer.util.TagOrder; @@ -128,9 +136,19 @@ import dev.wander.android.opentagviewer.util.rx.AccountReadPolicy; import dev.wander.android.opentagviewer.util.rx.RefreshPolicy; import dev.wander.android.opentagviewer.util.rx.RxFlows; +import dev.wander.android.opentagviewer.ble.BlePermissions; +import dev.wander.android.opentagviewer.ble.BleSoundTriggerPhase; +import dev.wander.android.opentagviewer.ble.BleSoundTriggerStatus; +import dev.wander.android.opentagviewer.ble.BleSoundTriggerUpdate; +import dev.wander.android.opentagviewer.ble.NearbyTagLabel; +import dev.wander.android.opentagviewer.ble.NearbyTagSighting; +import dev.wander.android.opentagviewer.ble.NearbyTagSightings; +import dev.wander.android.opentagviewer.ble.NearbyTagIndex; +import dev.wander.android.opentagviewer.ble.NearbyTagWatcher; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; import lombok.Data; @@ -141,6 +159,8 @@ public class MapsActivity extends AppCompatActivity implements IMapProvider.OnMa private static final String TAG = MapsActivity.class.getSimpleName(); private static final int LOCATION_PERMISSION_REQUEST_CODE = 1; + private static final int RING_PERMISSION_REQUEST_CODE = 2; + private static final int NEARBY_PERMISSION_REQUEST_CODE = 3; private static final int GOOGLE_LOGO_PADDING_BOTTOM_PX = 40; @@ -195,6 +215,60 @@ public class MapsActivity extends AppCompatActivity implements IMapProvider.OnMa private final Map beacons = new ConcurrentHashMap<>(); + /** The beaconId continuous ping is currently running for, or null if it is off. Only one + * runs at a time - see {@link #onClickRing}. */ + private String continuousPingBeaconId; + + /** The in-flight continuous ping loop, so leaving the screen stops the radio work rather + * than leaving it running in the background with nothing left to show its state. */ + private Disposable continuousPingDisposable; + + /** Which tag's ring button asked for BLE permission, so the result callback - which carries + * no context of its own - knows what to start once it is granted. */ + private String ringPermissionRequestBeaconId; + + /** + * Tags this phone can hear right now, and how full their batteries say they are. + * + *

Fed by a scan that runs only while this screen is resumed, so it is a display of what + * is audible rather than any kind of tracking. Entries age out on their own - see + * {@link NearbyTagSightings}. + */ + private final NearbyTagSightings nearbySightings = new NearbyTagSightings(); + + /** The in-flight nearby scan, disposed in {@link #onPause()} so the radio stops with the screen. */ + private Disposable nearbyWatchDisposable; + + /** + * Redraws the cards once a second while the nearby scan is running, so a nearby card's + * "heard N seconds ago" keeps counting up between sightings instead of only changing when + * one arrives. + * + *

A separate ticker rather than something {@link #onTagHeardNearby} drives, because a + * sighting fires roughly every one to three seconds while a tag is genuinely in range - so + * driving the redraw from sightings alone would repaint the line back to "0s" almost as + * often as it changed, and never show the gap growing in between. + */ + private Disposable nearbyStatusTickerDisposable; + + /** + * Whether this activity has already asked for the BLE permission the nearby watch needs. + * + *

Never reset for the life of the activity: the system dialog pauses this activity, so + * asking again from every {@code onResume} re-prompted the moment the user denied - an + * inescapable loop on Android 10 and below, and a silent auto-denied request burned on + * every resume above that. Granting from the dialog still takes effect immediately through + * {@code onRequestPermissionsResult}; a user who denied can still enable it later through + * the system settings, which resumes this activity and passes the granted check directly. + */ + private boolean nearbyBlePermissionRequested; + + /** The pending retry after the scan died mid-session - see {@link #onNearbyWatchEnded}. */ + private Disposable nearbyWatchRetryDisposable; + + /** The one place a Bluetooth sighting is persisted - see {@link AccessorySightingPersister}. */ + private AccessorySightingPersister sightingPersister; + /** Location history plus the "can this be drawn" rule. See BeaconLocationHistoryTest. */ private final BeaconLocationHistory beaconLocations = new BeaconLocationHistory(); @@ -427,6 +501,13 @@ protected void onCreate(Bundle savedInstanceState) { this.beaconRepo = new BeaconRepository( OpenTagViewerDatabase.getInstance(getApplicationContext())); + // The fetch this screen is about to run is what produces the drift measurement, so + // this is a place that starts Python anyway - see PythonDiagnostics. + PythonDiagnostics.attach(this); + + this.sightingPersister = new AccessorySightingPersister(this.beaconRepo, + new CachedPhoneLocation(new FusedPhoneLocation(this.getApplicationContext())), + this::onHeardHere); this.fusedLocationClient = LocationServices.getFusedLocationProviderClient(this); @@ -501,6 +582,8 @@ public void onMapReady(IMapProvider provider) { protected void onPause() { super.onPause(); + this.stopWatchingForNearbyTags(); + if (this.mapProvider != null) { IMapProvider.CameraPosition pos = this.mapProvider.getCameraPosition(); if (pos != null) { @@ -552,17 +635,206 @@ protected void onResume() { // on this screen until the periodic read came round. this.rereadTheAccountIfAllowed(true); this.reSchedulePeriodicTagLocationRefresher(); - + this.startWatchingForNearbyTags(); + // 调用高德地图的生命周期方法 if (this.mapProvider instanceof AMapProvider) { ((AMapProvider) this.mapProvider).onResume(); } } + /** + * Continuous ping stops when the screen does, not when the activity is destroyed. + * + *

Pressing Home does not destroy an activity, so disposing in {@code onDestroy} left the + * loop scanning and connecting over Bluetooth with the app in the background - burning the + * radio for a sound the user is no longer in a position to hear, and with no way to stop it + * short of coming back to this screen. {@code onDestroy} may not run for a long time, or at + * all before the process is killed. + * + *

{@code onStop} rather than {@code onPause}, which is a different question: pause + * fires for a dialog or the notification shade, and someone walking towards a tag by ear + * should not lose the ping to a passing notification. Stop means the screen is genuinely + * gone. + * + *

Through {@link #stopContinuousPing()} rather than disposing directly, so the card's + * button and spinner are reset too - otherwise returning to a stopped loop finds a card + * still captioned "Stop" with a spinner that will never move again. + */ + @Override + protected void onStop() { + super.onStop(); + this.stopContinuousPing(); + } + + /** + * Listen for the user's own tags for as long as this screen is in front of somebody. + * + *

Tied to the screen rather than to a service, deliberately. Nothing here runs in + * the background: this starts in {@code onResume} and is disposed in {@code onPause}, so the + * radio is on only while a person is looking at the result. That keeps it a display feature + * rather than a tracking one, and a scan next to a lit screen costs little beside the screen. + * + *

Silent when it cannot run. No permission, Bluetooth off, or no tags with usable + * accessory JSON all mean no sightings, which renders as no badges - the same as hearing + * nothing. None of those is worth interrupting somebody looking at a map for. + */ + private void startWatchingForNearbyTags() { + this.stopWatchingForNearbyTags(); + + // Asked for here rather than left to the ring button: this scan is what feeds the + // battery/audible badge on every card, so it wants to be running as soon as the screen + // opens, not only once somebody has separately triggered a ring. Silently doing nothing + // without permission, as NearbyTagWatcher itself does, would just look like every tag + // is permanently out of range. + // + // Before the empty-list check, so a first launch - where the beacons have not loaded + // yet - still asks. And at most once per activity: the system dialog pauses this + // activity, so a request fired from every onResume re-prompted the instant the user + // denied, a loop with no way out on Android 10 and below. + if (!BlePermissions.granted(this)) { + if (!this.nearbyBlePermissionRequested) { + this.nearbyBlePermissionRequested = true; + Log.d(TAG, "Requesting BLE permission(s) to watch for nearby tags"); + ActivityCompat.requestPermissions( + this, BlePermissions.required(), NEARBY_PERMISSION_REQUEST_CODE); + } + return; + } + + final Map accessoryJsonByBeaconId = new HashMap<>(); + for (final var entry : this.beacons.entrySet()) { + final String accessoryJson = entry.getValue().getInfo().getOwnedBeaconAccessoryJson(); + if (accessoryJson != null && !accessoryJson.isEmpty()) { + accessoryJsonByBeaconId.put(entry.getKey(), accessoryJson); + } + } + if (accessoryJsonByBeaconId.isEmpty()) { + // Ordinary on a cold start: the beacons load asynchronously and are not here yet. + // addBeaconToCurrent starts the watch once they arrive - see there. + return; + } + + this.nearbyWatchDisposable = new NearbyTagWatcher( + AppDependencies.accessoryMacResolver(), this.sightingPersister::onSighting, + ScanSettings.SCAN_MODE_LOW_LATENCY) + .watch(this.getApplicationContext(), accessoryJsonByBeaconId) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::onTagHeardNearby, + error -> Log.w(TAG, "Nearby tag watch ended unexpectedly", error), + this::onNearbyWatchEnded); + + this.nearbyStatusTickerDisposable = Observable + .interval(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) + .subscribe(tick -> this.updateBeaconCards()); + } + + /** + * The scan died mid-session rather than being stopped: Bluetooth toggled off, or the + * platform refused the scan (e.g. too many scan starts in a short window). + * + *

Cancellation does not come through here - disposing skips onComplete - so this only + * runs for genuine mid-session death. Two things then must not keep happening: the + * once-per-second ticker redrawing every card for a scan that can no longer produce + * sightings, and the badges claiming tags are here based on a radio nobody is listening + * to. And one thing must: a retry, or the nearby feature stays dead for the rest of the + * session even after Bluetooth comes back. One attempt per 30 seconds is far under the + * platform's scan-start budget, and each failed attempt completes again and reschedules, + * so it self-heals whenever the radio returns. + */ + private void onNearbyWatchEnded() { + Log.i(TAG, "The nearby tag watch ended mid-session; retrying in 30s"); + this.stopWatchingForNearbyTags(); + this.updateBeaconCards(); + + this.nearbyWatchRetryDisposable = Observable + .timer(30, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) + .subscribe(tick -> this.startWatchingForNearbyTags()); + } + + private void stopWatchingForNearbyTags() { + if (this.nearbyWatchDisposable != null && !this.nearbyWatchDisposable.isDisposed()) { + this.nearbyWatchDisposable.dispose(); + } + this.nearbyWatchDisposable = null; + if (this.nearbyStatusTickerDisposable != null + && !this.nearbyStatusTickerDisposable.isDisposed()) { + this.nearbyStatusTickerDisposable.dispose(); + } + this.nearbyStatusTickerDisposable = null; + if (this.nearbyWatchRetryDisposable != null + && !this.nearbyWatchRetryDisposable.isDisposed()) { + this.nearbyWatchRetryDisposable.dispose(); + } + this.nearbyWatchRetryDisposable = null; + // Nothing on screen may go on claiming a tag is here once we have stopped listening. + this.nearbySightings.clear(); + } + + /** + * Redraws one card when its tag is heard. + * + *

Only that card, and only when it exists: a sighting arrives per advertisement, which is + * every second or two per tag, and redrawing the whole row that often would be visible. + */ + private void onTagHeardNearby(final NearbyTagSighting sighting) { + this.nearbySightings.record(sighting); + + final FrameLayout card = this.dynamicCardsForTag.get(sighting.getBeaconId()); + if (card != null) { + this.showNearbyStatusOn(card, sighting, System.currentTimeMillis()); + } + } + + /** + * How recently a sighting has to have arrived for {@link #showNearbyStatusOn} to light the + * pulse dot rather than dim it. + * + *

Under the one-to-three-second gap between advertisements a tag in range genuinely + * produces, so the dot visibly lights and dims once per sighting instead of just staying lit + * - which is the live-activity read this is for, in place of a number that either sat at + * "0s" permanently (driven only by sightings) or needed a second ticker to mean anything. + */ + private static final long PULSE_WINDOW_MS = 1_000L; + + /** + * Replaces a card's "last updated" line while its tag is audible, and lights the pulse dot + * beside it if a sighting arrived within {@link #PULSE_WINDOW_MS}. + * + *

The line says something different from "last updated two hours ago", which describes + * when Apple's network last reported it: a sighting means this phone can hear it right now. + * Showing both would need a taller card, and the row is already measured to the pixel - see + * {@code TagCardLayoutTest}. + * + *

The line - and the dot with it - go back to the timestamp on their own once the + * sighting ages out, because nothing announces that a tag has left; we simply stop hearing + * it. See the {@code else} branch in {@code updateBeaconCards} that calls this only when a + * fresh sighting exists. + */ + private void showNearbyStatusOn( + final FrameLayout card, final NearbyTagSighting sighting, final long nowMs) { + final TextView line = card.findViewById(R.id.device_last_update); + line.setText(this.getString(R.string.nearby_now_with_battery_and_signal, + this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())), + NearbyTagLabel.signalStrengthBars(sighting.getRssi()))); + + // Never negative: nowMs can be a hair behind seenAtMs when this runs right off the scan + // callback, before the clock the caller reads has ticked past it. + final long msSinceSighting = Math.max(0, nowMs - sighting.getSeenAtMs()); + final boolean pulsing = msSinceSighting < PULSE_WINDOW_MS; + + final ImageView pulse = card.findViewById(R.id.device_nearby_pulse); + pulse.setVisibility(VISIBLE); + pulse.setImageTintList(ColorStateList.valueOf(MaterialColors.getColor(card, pulsing + ? com.google.android.material.R.attr.colorPrimary + : com.google.android.material.R.attr.colorOutlineVariant))); + } + @Override protected void onDestroy() { super.onDestroy(); - + // 调用高德地图的生命周期方法 if (this.mapProvider instanceof AMapProvider) { ((AMapProvider) this.mapProvider).onDestroy(); @@ -1272,8 +1544,159 @@ public void onClickRefresh(View view) { }); } + /** + * Toggle continuous ping (repeated scan + play-sound-nearby, see + * {@code AccessorySoundTrigger#playSoundContinuously}) for this card's tag - on until tapped + * again, unlike {@code DeviceInfoActivity}'s one-shot "Play Sound Nearby". + * + *

Only one tag at a time: starting it for a different tag stops whichever was running, + * since it is one Bluetooth radio and one thing to listen for. + */ public void onClickRing(View view) { - Log.i(TAG, "The ring button was clicked"); + Log.d(TAG, "The ring button was clicked"); + + final String beaconId = this.dynamicCardsForTag.entrySet() + .stream().filter(kvp -> kvp.getValue().findViewById(R.id.device_ring_button_container) == view) + .map(Map.Entry::getKey) + .findFirst() + .orElseThrow(() -> new RuntimeException("Click ring event was raised by a Beacon Device's card, but the beaconId could not be found for it!")); + + final boolean wasRunningForThisTag = beaconId.equals(this.continuousPingBeaconId); + this.stopContinuousPing(); + if (wasRunningForThisTag) { + return; + } + + if (!BlePermissions.granted(this)) { + Log.d(TAG, "Requesting BLE permission(s) before starting continuous ping for beaconId=" + beaconId); + this.ringPermissionRequestBeaconId = beaconId; + ActivityCompat.requestPermissions(this, BlePermissions.required(), RING_PERMISSION_REQUEST_CODE); + return; + } + + this.startContinuousPing(beaconId); + } + + private void startContinuousPing(final String beaconId) { + final BeaconData beaconData = this.beacons.get(beaconId); + if (beaconData == null) { + Log.w(TAG, "Cannot start continuous ping: no loaded data for beaconId=" + beaconId); + return; + } + final String accessoryJson = beaconData.getInfo().getOwnedBeaconAccessoryJson(); + + this.continuousPingBeaconId = beaconId; + final FrameLayout container = this.dynamicCardsForTag.get(beaconId); + if (container != null) { + TagCardHelper.toggleRingActive(container, true); + } + + this.continuousPingDisposable = AppDependencies.accessorySoundTrigger() + .playSoundContinuously(this.getApplicationContext(), accessoryJson) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + update -> this.handleContinuousPingUpdate(beaconId, update), + error -> { + // playSoundContinuously's contract is to never error onto this path - + // see its interface doc - so reaching here means a bug in that + // contract, not an ordinary "not found nearby" or "no permission". + Log.e(TAG, "Continuous ping stopped unexpectedly for beaconId=" + beaconId, error); + this.stopContinuousPing(); + }); + } + + /** + * Shows continuous ping's current phase on the card's ring label - "Scanning...", + * "Connecting...", "Sending..." - so it reads as active work rather than nothing happening, + * without a toast firing every few seconds for as long as it runs. + * + *

Between cycles ({@link BleSoundTriggerPhase#DONE}) a successful write shows "Ringing!" + * rather than jumping straight back to "Stop" - the write itself is near-instant, so without + * this the sequence reads as scan, connect, done, with no visible moment where it actually + * worked. It shows for the whole {@code CONTINUOUS_PING_PAUSE_MS} gap before the next cycle's + * scan starts, which is also roughly how long an AirTag's chirp lasts. A not-found or failed + * attempt goes back to "Stop" directly and keeps looping - the tag may come into range on + * the next cycle. {@link BleSoundTriggerStatus#MISSING_PERMISSION} and + * {@link BleSoundTriggerStatus#NO_CANDIDATE_MACS} do not: nothing about waiting and trying + * again fixes either, so looping on them is pure battery burn with no chance of succeeding - + * this stops the loop and says why instead. + */ + private void handleContinuousPingUpdate(final String beaconId, final BleSoundTriggerUpdate update) { + Log.d(TAG, "Continuous ping update for beaconId=" + beaconId + ": " + update.getPhase() + + (update.getResult() == null ? "" : " (" + update.getResult().getStatus() + ")")); + + this.sightingPersister.keepWhatTheSightingProved(beaconId, update); + + // A card for a beaconId other than the one this loop is for stopped existing (e.g. the + // tag left the visible list) or continuous ping was stopped/switched to another tag + // since this update was emitted - either way, there is nothing left to show it on. + if (!beaconId.equals(this.continuousPingBeaconId)) { + return; + } + + if (update.getPhase() == BleSoundTriggerPhase.DONE) { + final BleSoundTriggerStatus status = update.getResult().getStatus(); + if (status == BleSoundTriggerStatus.MISSING_PERMISSION + || status == BleSoundTriggerStatus.NO_CANDIDATE_MACS) { + Log.w(TAG, "Stopping continuous ping for beaconId=" + beaconId + + ": unrecoverable status " + status); + this.stopContinuousPing(); + Toast.makeText(this, status == BleSoundTriggerStatus.MISSING_PERMISSION + ? R.string.play_sound_permission_denied + : R.string.play_sound_no_candidate_macs, + LENGTH_LONG).show(); + return; + } + } + + final FrameLayout container = this.dynamicCardsForTag.get(beaconId); + if (container == null) { + return; + } + + final int labelRes; + switch (update.getPhase()) { + case CONNECTING: + labelRes = R.string.ring_status_connecting; + break; + case TRIGGERING: + labelRes = R.string.ring_status_triggering; + break; + case DONE: + labelRes = update.getResult().getStatus() == BleSoundTriggerStatus.SUCCESS + ? R.string.ring_status_success + : R.string.stop_ringing; + break; + case SCANNING: + default: + labelRes = R.string.ring_status_scanning; + break; + } + TagCardHelper.setRingLabel(container, this.getString(labelRes)); + + // The spinner runs for SCANNING/CONNECTING/TRIGGERING and stops at DONE - a label + // alone ("Scanning...", "Connecting...") can sit on screen for several seconds with + // nothing else moving, which reads as stuck rather than as work in progress. + TagCardHelper.setRingLoading(container, update.getPhase() != BleSoundTriggerPhase.DONE); + } + + private void stopContinuousPing() { + if (this.continuousPingDisposable != null && !this.continuousPingDisposable.isDisposed()) { + this.continuousPingDisposable.dispose(); + } + this.continuousPingDisposable = null; + + if (this.continuousPingBeaconId != null) { + final FrameLayout container = this.dynamicCardsForTag.get(this.continuousPingBeaconId); + if (container != null) { + TagCardHelper.toggleRingActive(container, false); + // In case this stopped mid-attempt (spinner showing) rather than between + // cycles - otherwise the icon stays hidden behind a spinner that will never + // update again. + TagCardHelper.setRingLoading(container, false); + } + } + this.continuousPingBeaconId = null; } public void onClickMoreForDevice(View view) { @@ -1580,30 +2003,114 @@ private void offerICloudSetupIfDue() { var async = new KeychainMembershipRepository( UserAuthDataStore.getInstance(this.getApplicationContext()), new AppCryptographyUtil()) - .get() + .state() .firstOrError() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( - held -> ICloudSetupOfferDialog.offerIfDue( - this, this.userSettings, held.isPresent(), this::recordICloudOffer), + state -> this.respondToTheMembershipState(state), error -> Log.w(TAG, "Could not tell whether an account is linked, so not offering" + " to connect one", error)); } - /** Persist the answer, and act on it if they said yes. */ - private void recordICloudOffer(final boolean accepted) { - var async = this.userSettingsRepo.storeUserSettings(this.userSettings) + /** + * Three situations, and only one of them is the first-time offer. + * + *

An account that was connected and can no longer be read is not a new user. The + * membership read used to collapse "never joined" and "joined but undecryptable" into the + * same empty answer, so a device whose secure storage had moved on was offered the + * first-time setup - once, silently - and if that offer was declined the app then behaved + * as though iCloud had never been wanted. Nothing said anything was wrong; the account reads + * simply stopped working. + * + *

So the broken case gets its own screen, saying what happened and that the tags and + * their history are untouched. It is not gated on the one-time flag: this is a fault to fix + * rather than a preference to express, and it stops appearing the moment it is fixed. + */ + private void respondToTheMembershipState(final KeychainMembershipRepository.MembershipState state) { + switch (state) { + case HELD: + // Already reading the account. Nothing to offer and nothing wrong. + return; + case KEYS_GONE: + // Explainable and not a fault: the keystore key went away and the data it wrote + // stayed. Nothing to report, and something for them to do. + Log.w(TAG, "The keystore key for the membership is gone, so telling them to" + + " connect the account again rather than offering it as though new"); + this.askThemToReconnectTheAccount(); + return; + case UNREADABLE: + // **The key is present and it still does not open the data, which is a bug.** + // Nothing a user did causes this, so an explanation would be an apology for + // something they cannot act on - the report is the useful thing to offer, and + // it is the same screen an unexplainable import failure goes to. + Log.e(TAG, "The membership is stored and its key is present, and it still does" + + " not decrypt - sending them to make a report"); + this.startActivity(ErrorReportActivity.intentFor(this, + getString(R.string.error_report_cause_membership_unreadable), + R.string.error_report_body_membership)); + return; + case NONE: + default: + this.offerICloudSetupTo(false); + } + } + + private void askThemToReconnectTheAccount() { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.icloud_membership_unreadable_title) + .setMessage(R.string.icloud_membership_unreadable_message) + .setPositiveButton(R.string.icloud_offer_set_up_now, + (dialog, which) -> this.actOnTheICloudOffer(true)) + .setNegativeButton(R.string.icloud_offer_not_now, (dialog, which) -> { }) + .show(); + } + + /** + * Ask, and write down that we asked - immediately, and on settings read here. + * + *

The prompt was coming back, and this is why. {@code offerIfDue} records the offer + * by setting a flag on the settings object it is handed, and that used to be + * {@code this.userSettings} - a field {@link #onResume} replaces with a fresh read on every + * single resume. So the sequence was: the dialog goes up and marks the object it was given; + * the activity resumes and the field becomes a different object, one still saying the offer + * was never made; the user answers; and the answer saves *that* object. The flag never + * reached storage, and the prompt returned on the next launch, and the next. + * + *

Two changes, and both are needed. The settings are read here rather than taken from the + * field, so nothing else can swap the object out underneath the dialog. And the write happens + * when the dialog is shown, not when it is answered - the gap between those was the + * race, and it also means a dialog dismissed by the activity being destroyed still counts, + * which is what {@code offerIfDue} promises. + */ + private void offerICloudSetupTo(final boolean hasLinkedAccount) { + final UserSettings settings = this.userSettingsRepo.getUserSettings(); + + final Dialog offered = ICloudSetupOfferDialog.offerIfDue( + this, settings, hasLinkedAccount, this::actOnTheICloudOffer); + + if (offered == null) { + return; + } + + // Keeps the field in step for anything else reading it before the next resume. It is the + // stored copy that decides this next launch, and that is written below. + this.userSettings = settings; + + var async = this.userSettingsRepo.storeUserSettings(settings) .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(() -> { - if (accepted) { - Log.i(TAG, "taking them to connect an iCloud account"); - this.fetchFromICloudLauncher.launch( - new Intent(this, FetchFromICloudActivity.class)); - } - }, error -> Log.e(TAG, "Failed to record the iCloud offer", error)); + .subscribe(() -> Log.i(TAG, "recorded that the iCloud offer was made"), + error -> Log.e(TAG, "Failed to record the iCloud offer, so it will be" + + " made again on the next launch", error)); + } + + /** Act on the answer. Recording that it was asked already happened, when it was shown. */ + private void actOnTheICloudOffer(final boolean accepted) { + if (accepted) { + Log.i(TAG, "taking them to connect an iCloud account"); + this.fetchFromICloudLauncher.launch(new Intent(this, FetchFromICloudActivity.class)); + } } private static boolean isAccountRestoreFailure(Throwable t) { @@ -1696,6 +2203,24 @@ private synchronized void addBeaconToCurrent(final List allBe } this.beacons.put(beaconId, new BeaconData(beacon, Collections.emptyList())); }); + + // The nearby watch could not start from onResume on a cold launch: it reads + // this.beacons, which was still empty because this load runs asynchronously, and + // nothing retried once the tags arrived - so the whole session had no pulse, no + // Nearby line, and no passive alignment correction until the app was backgrounded + // and reopened. Started here, once, when there is finally something to watch for. + // Guarded on the disposable so the periodic account refresh, which also lands here, + // does not bounce a running scan - Android silently blocks an app that starts scans + // too often. + if (!newBeaconInformation.isEmpty() && this.nearbyWatchDisposable == null) { + // Re-checked on the main thread: this load finishes on a background thread, and by + // the time the post runs, onResume may have started the watch already. + this.runOnUiThread(() -> { + if (this.nearbyWatchDisposable == null && !this.isFinishing()) { + this.startWatchingForNearbyTags(); + } + }); + } } private synchronized void addBeaconLocationsToCurrent(final Map> newItems) { @@ -1759,6 +2284,21 @@ private Observable> reverseGeocode(double latitude, double longitu .subscribeOn(Schedulers.io()); } + /** + * Puts a position this phone just heard onto the map, without waiting for a fetch. + * + *

The map draws from {@link #beaconLocations}, which until now only the network fetch + * filled. A tag heard over Bluetooth was therefore written to the database and left off the + * screen until the next scheduled refresh - so the map went on showing Apple's older answer + * for a tag that was audible in the same room. Merged through the same history object the + * fetch uses, so the newer of the two wins on its own and no special case is needed for + * which source a position came from. + */ + private void onHeardHere(final String beaconId, final BeaconLocationReport report) { + this.beaconLocations.merge(beaconId, List.of(report)); + this.runOnUiThread(this::showLastDeviceLocations); + } + private synchronized void showLastDeviceLocations() { for (BeaconData beaconData : this.beacons.values()) { BeaconInformation beacon = beaconData.getInfo(); @@ -1891,6 +2431,10 @@ private synchronized void updateBeaconCards() { HorizontalScrollView scrollContainer = this.findViewById(R.id.tags_scrollable_area); LinearLayout cardsContainer = this.findViewById(R.id.tags_scroll_container); + // Read once, not per card: it does not change between cards, and BlePermissions.granted + // is a real permission check, not a field read. + final boolean canRing = BlePermissions.granted(this); + // remove all beacons that had cards that are now gone for (var beaconId : this.dynamicCardsForTag.keySet()) { if (!this.beacons.containsKey(beaconId) || !this.beaconLocations.isDrawable(beaconId)) { @@ -1960,6 +2504,14 @@ private synchronized void updateBeaconCards() { v.setLayoutParams(params); + // The ring button toggles a Bluetooth scan, so it has no honest job to do without + // the permission that scan needs - showing it only to have every tap re-ask for + // permission would be a worse experience than not showing it. Re-applied on every + // redraw rather than once, so a permission grant or revocation while the screen is + // open is reflected without needing anything else to notice. + final View ringButtonContainer = v.findViewById(R.id.device_ring_button_container); + ringButtonContainer.setVisibility(canRing ? VISIBLE : GONE); + // the title TextView deviceNameView = v.findViewById(R.id.device_name); deviceNameView.setText(beacon.getName()); @@ -1990,14 +2542,23 @@ private synchronized void updateBeaconCards() { deviceLocation.setText(geoLocation.getAddressLine(0)); } - // the last updated time + // the last updated time - unless the tag is audible right now, which is both newer + // and more useful than when Apple's network last reported it. See + // showNearbyStatusOn; a sighting ages out on its own, so this line comes back. TextView deviceLastUpdate = v.findViewById(R.id.device_last_update); - final var timeAgo = DateUtils.getRelativeTimeSpanString( - lastLocation.getTimestamp(), - now, - DateUtils.MINUTE_IN_MILLIS - ).toString(); - deviceLastUpdate.setText(this.getString(R.string.last_updated_x, timeAgo)); + final NearbyTagSighting heardNow = this.nearbySightings.freshFor(beaconId, now); + if (heardNow != null) { + this.showNearbyStatusOn(v, heardNow, now); + } else { + final var timeAgo = DateUtils.getRelativeTimeSpanString( + lastLocation.getTimestamp(), + now, + DateUtils.MINUTE_IN_MILLIS + ).toString(); + deviceLastUpdate.setText(this.getString(R.string.last_updated_x, timeAgo)); + // Nothing live to pulse for once the sighting has aged out. + ((ImageView) v.findViewById(R.id.device_nearby_pulse)).setVisibility(GONE); + } // **Put an existing card where it now belongs.** Cards are created once and reused, // so a card added before the user rearranged anything keeps its original slot @@ -2135,6 +2696,7 @@ private Observable>> fetchLastReports(fin // asking, and asks about whatever it was given. return this.beaconRepo.toScheduledAccessoryRequests(beaconIdToPlist) .doOnSubscribe(__ -> this.markFetchStarted()) + .doOnNext(this::armLongFetchBannerIfSlow) .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack)) .doOnNext(reports -> this.refreshPolicy.markFetched(now)) // on success, update this time. .doFinally(this::markFetchFinished); @@ -2144,6 +2706,7 @@ private Observable>> fetchLastReports(fin Log.d(TAG, "Preparing to fetch location reports for the last " + hoursToGoBack + " hours!"); return this.beaconRepo.toAccessoryRequests(beaconIdToPlist) .doOnSubscribe(__ -> this.markFetchStarted()) + .doOnNext(this::armLongFetchBannerIfSlow) .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack)) .doFinally(this::markFetchFinished); } @@ -2201,6 +2764,7 @@ private Observable>> fetchLastReportsFor( // Not Map.of - see BeaconRepository.plistFallback. A self-generated tag has no plist. return this.beaconRepo.toAccessoryRequests(BeaconRepository.plistFallback(beaconId, pList)) .doOnSubscribe(__ -> this.markFetchStarted()) + .doOnNext(this::armLongFetchBannerIfSlow) .flatMap(requests -> this.appleService.getLastReports(requests, hoursToGoBack)) .flatMap(this.beaconRepo::storeFetchResult) .doFinally(this::markFetchFinished); @@ -2219,11 +2783,51 @@ private Observable>> fetchLastReportsFor( * part-way through discards the work for all of them and the next launch starts over. */ private void markFetchStarted() { + this.longFetchBannerHandler.post(this.bannerState::fetchStarted); + } + + /** + * Arms the banner, but only for a batch that is actually going to be slow. + * + *

Counting a fetch and warning about one are now separate things. This used to arm + * on every fetch that passed six seconds, which on a slow network is most of them - so the + * message showed up during loads that finished immediately, and a warning that appears when + * nothing is wrong is one people stop reading. The wait it exists for is the key search + * described above, and whether that search is long is known before the request goes out: it + * depends on how far back the accessory's alignment record starts it. + * + *

Asked of the requests rather than of the tags on screen, because the scheduled fetch + * drops tags that are ignored or backing off - an unaligned tag nobody is fetching should + * not put up a banner about a wait that is not happening. + * + *

The lookup is a database read and the banner is six seconds away, so there is time; and + * it re-checks that a fetch is still in flight before arming, since a quick batch can finish + * while the question is being answered. + */ + private void armLongFetchBannerIfSlow(final List requests) { + var async = this.beaconRepo.aFetchOfTheseWouldBeSlow(requests) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + slow -> this.armLongFetchBanner(slow), + // On doubt, warn. Silence through a three-minute wait is the failure + // this mechanism exists to prevent; a banner during a quick fetch is + // merely untidy. + error -> { + Log.w(TAG, "Could not tell whether this fetch will be slow," + + " so assuming it might be", error); + this.armLongFetchBanner(true); + }); + } + + private void armLongFetchBanner(final boolean slow) { this.longFetchBannerHandler.post(() -> { - if (this.bannerState.fetchStarted()) { - this.longFetchBannerHandler.postDelayed( - this.showLongFetchBanner, SHOW_LONG_FETCH_BANNER_AFTER_MS); + if (!slow || !this.bannerState.isFetching()) { + return; } + this.longFetchBannerHandler.removeCallbacks(this.showLongFetchBanner); + this.longFetchBannerHandler.postDelayed( + this.showLongFetchBanner, SHOW_LONG_FETCH_BANNER_AFTER_MS); }); } @@ -2318,6 +2922,44 @@ private void performNativePermissionRequest() { @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + if (requestCode == RING_PERMISSION_REQUEST_CODE) { + final String beaconId = this.ringPermissionRequestBeaconId; + this.ringPermissionRequestBeaconId = null; + + // Re-checked against BlePermissions.granted rather than the grantResults array + // directly - one place decides what "enough" means, matching DeviceInfoActivity's + // own permission flow and BlePermissions' class doc. + if (beaconId != null && BlePermissions.granted(this)) { + Log.i(TAG, "BLE permission granted; starting continuous ping for beaconId=" + beaconId); + this.startContinuousPing(beaconId); + } else { + Log.i(TAG, "BLE permission refused; not starting continuous ping"); + Toast.makeText(this, R.string.play_sound_permission_denied, LENGTH_LONG).show(); + } + return; + } + + if (requestCode == NEARBY_PERMISSION_REQUEST_CODE) { + // Re-checked against BlePermissions.granted for the same reason as above: one place + // decides what "enough" means. + if (BlePermissions.granted(this)) { + Log.i(TAG, "BLE permission granted; starting the nearby tag watch"); + this.startWatchingForNearbyTags(); + } else { + Log.i(TAG, "BLE permission refused; not watching for nearby tags"); + // Said out loud because the refusal also keeps the ring button hidden (see + // updateBeaconCards), and the request fires only once per activity - so with + // no toast, someone who taps Deny is left with no visible trace that nearby + // and ringing exist, and no in-app path back to them short of the system + // settings. + Toast.makeText(this, R.string.play_sound_permission_denied, LENGTH_LONG).show(); + } + // The ring button on every card was hidden while this was undecided - see + // updateBeaconCards - and needs to be shown or stay hidden depending on the answer. + this.updateBeaconCards(); + return; + } + if (requestCode != LOCATION_PERMISSION_REQUEST_CODE) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); return; diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java index bb21f65a..74236046 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java @@ -214,7 +214,7 @@ protected void onCreate(Bundle savedInstanceState) { } this.binding = DataBindingUtil.setContentView(this, R.layout.activity_my_devices_list); - WindowPaddingUtil.insertUITopPadding(this.binding.getRoot()); + WindowPaddingUtil.insetForSystemBars(this.binding.getRoot()); this.binding.setHandleClickBack(this::handleEndActivity); if (this.getSupportActionBar() != null) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java b/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java index 6d2effde..5d1f543f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java @@ -10,7 +10,10 @@ import dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore; import dev.wander.android.opentagviewer.db.repo.UserSettingsRepository; +import dev.wander.android.opentagviewer.db.repo.model.UserSettings; import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.service.NearbyScanService; +import io.reactivex.rxjava3.schedulers.Schedulers; public class OpenAirTagApplication extends PyApplication { private static final String TAG = OpenAirTagApplication.class.getSimpleName(); @@ -25,6 +28,38 @@ public void onCreate() { this.setupTheme(); this.setupSystemColors(); + this.resumeBackgroundScanIfEnabled(); + } + + /** + * Brings the background scan back after the process was gone. + * + *

The setting is the state, and the service is only its consequence. A service does + * not survive a reboot, a force-stop or the system reclaiming memory, so without this the + * switch would silently stop meaning anything and the only cure would be toggling it off and + * on - which reads as the setting having been forgotten. + * + *

Off by default, so this starts nothing for anyone who has not asked. Read + * asynchronously because the setting lives in a DataStore and Application#onCreate blocks + * the first activity. + */ + private void resumeBackgroundScanIfEnabled() { + // Off the main thread: the read hits a DataStore, and this runs before the first + // activity is created. + Schedulers.io().scheduleDirect(() -> { + try { + final UserSettings settings = + new UserSettingsRepository(UserSettingsDataStore.getInstance(this)) + .getUserSettings(); + + if (settings.shouldScanInBackground()) { + Log.i(TAG, "Background scanning is on; starting the service"); + NearbyScanService.start(this); + } + } catch (final Exception e) { + Log.w(TAG, "Could not read whether to scan in the background", e); + } + }); } /** diff --git a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java index 325e2bd2..a7aaae7e 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java @@ -1,11 +1,17 @@ package dev.wander.android.opentagviewer; +import androidx.core.content.ContextCompat; +import androidx.core.app.ActivityCompat; +import android.content.pm.PackageManager; +import android.Manifest; import static android.view.View.GONE; import static android.view.View.VISIBLE; import static android.view.View.inflate; import static dev.wander.android.opentagviewer.util.android.TextChangedWatcherFactory.justWatchOnChanged; import android.content.Intent; +import android.media.Ringtone; +import android.media.RingtoneManager; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -20,6 +26,7 @@ import android.widget.CompoundButton; import android.widget.LinearLayout; import android.widget.TextView; +import com.google.android.material.slider.Slider; import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; @@ -52,6 +59,7 @@ import java.util.Set; import java.util.stream.Collectors; +import dev.wander.android.opentagviewer.service.NearbyScanService; import dev.wander.android.opentagviewer.anisette.AdiLibraryImporter; import dev.wander.android.opentagviewer.anisette.AdiLibraryManifest; import dev.wander.android.opentagviewer.anisette.AnisetteSource; @@ -174,9 +182,11 @@ protected void onCreate(Bundle savedInstanceState) { this.themeChoices.add(this.getString(R.string.dark_theme)); this.binding = DataBindingUtil.setContentView(this, R.layout.activity_settings); - WindowPaddingUtil.insertUITopPadding(binding.getRoot()); - // The last row is the debug switch, and the navigation bar was sitting on top of it. - WindowPaddingUtil.insertUIBottomPadding(this.findViewById(R.id.settings_scroll_area)); + WindowPaddingUtil.insetForSystemBars(binding.getRoot()); + // The bottom inset used to be applied to the scroll area here as well - the debug switch + // is the last row and the navigation bar sat on top of it. insetForSystemBars above now + // does that for the whole screen, as it does for every other one, so a second call would + // reserve the bar's height twice. this.binding.setHandleClickBack(this::handleEndActivity); this.binding.setOnClickFetchFromAccount(this::onClickFetchFromAccount); this.binding.setOnClickUnlinkAccount(this::onClickUnlinkAccount); @@ -192,6 +202,7 @@ protected void onCreate(Bundle savedInstanceState) { this.binding.setCurrentMapProvider(this.getCurrentMapProviderUiString()); this.binding.setIsDebugDataEnabled(Optional.ofNullable(this.currentSettings.getEnableDebugData()).orElse(false)); this.binding.setIsShowAppleDevicesEnabled(this.currentSettings.shouldShowAppleDevices()); + this.binding.setIsScanInBackgroundEnabled(this.currentSettings.shouldScanInBackground()); this.binding.setOnClickAppleDevicesHelpLink(this::onClickAppleDevicesHelpLink); this.binding.setIsSystemColorsSupported(DynamicColors.isDynamicColorAvailable()); this.binding.setIsSystemColorsEnabled( @@ -207,9 +218,14 @@ protected void onCreate(Bundle savedInstanceState) { MaterialSwitch appleDevices = this.findViewById(R.id.settings_show_apple_devices); appleDevices.setOnCheckedChangeListener(this::onShowAppleDevicesChange); + MaterialSwitch backgroundScan = this.findViewById(R.id.settings_scan_in_background); + backgroundScan.setOnCheckedChangeListener(this::onScanInBackgroundChange); + MaterialSwitch systemColors = this.findViewById(R.id.settings_app_use_system_colors); systemColors.setOnCheckedChangeListener(this::onUseSystemColorsChange); + this.setupLeftBehindSettings(); + this.setupUserInfo(); var async = this.github.getSuggestedServers().subscribe(suggestedServers -> { @@ -263,6 +279,95 @@ private void onShowAppleDevicesChange(CompoundButton buttonView, boolean isCheck + "; they are " + (isChecked ? "also" : "no longer") + " searched for"); } + /** + * Starts or stops the background scan, and saves the choice. + * + *

Acted on immediately rather than at the next launch, unlike its neighbour above. + * Somebody turning this on is asking for something to start happening, and somebody turning + * it off is asking for it to stop - most likely because they have just seen the notification + * and want it gone. Deferring either would read as the switch not working. + */ + private void onScanInBackgroundChange(CompoundButton buttonView, boolean isChecked) { + if (this.currentSettings.shouldScanInBackground() == isChecked) { + return; + } + + this.currentSettings.setScanInBackground(isChecked); + this.binding.setIsScanInBackgroundEnabled(isChecked); + this.saveSettings(); + + if (isChecked) { + this.askToShowTheNotification(); + NearbyScanService.start(this); + } else { + NearbyScanService.stop(this); + } + + Log.i(TAG, "Background scanning is now " + (isChecked ? "on" : "off")); + } + + /** + * Asks for permission to show the service's notification, on the versions that require it. + * + *

The service runs either way, and that is the problem. Android 13 made + * notifications a runtime permission, and a foreground service whose notification is + * suppressed still scans - so somebody who turned this on would get the battery cost and no + * sign that anything was happening, which is the one thing a permanent notification is for. + * + *

Asked at the moment it becomes relevant rather than at startup: a prompt at first + * launch, before anything wants to notify, is one people dismiss without reading. Nothing + * hangs on the answer - a refusal leaves the service running and invisible, which is the + * user's call to make. + */ + private void askToShowTheNotification() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return; + } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED) { + return; + } + + ActivityCompat.requestPermissions( + this, new String[] {Manifest.permission.POST_NOTIFICATIONS}, + NOTIFICATION_PERMISSION_REQUEST_CODE); + } + + /** Request code for {@link #askToShowTheNotification()}. Nothing depends on the answer. */ + private static final int NOTIFICATION_PERMISSION_REQUEST_CODE = 2001; + + /** + * Re-posts the service's notification once permission arrives. + * + *

Because the grant lands after the service has already started. The dialog is + * asynchronous, so the service goes to the foreground while notifications are still denied, + * and the system drops the notification it posts. Nothing re-posts it afterwards, so the + * service scans invisibly for the rest of its life - permission granted, status bar empty, + * which is exactly the state this setting must not leave somebody in. + * + *

Starting an already-running service is cheap and safe: it re-enters + * {@code onStartCommand}, which posts the notification again and leaves the existing scan + * alone. + */ + @Override + public void onRequestPermissionsResult( + final int requestCode, final String[] permissions, final int[] grantResults) { + + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode != NOTIFICATION_PERMISSION_REQUEST_CODE) { + return; + } + + final boolean granted = grantResults.length > 0 + && grantResults[0] == PackageManager.PERMISSION_GRANTED; + + if (granted && this.currentSettings.shouldScanInBackground()) { + Log.i(TAG, "Notification permission granted; re-posting the service notification"); + NearbyScanService.start(this); + } + } + /** * Opens the issue tracking real support for locating the owner's own devices. * @@ -1178,4 +1283,106 @@ public void setButtonStage(boolean successStage) { } } } + + /** + * Wires the two left-behind settings: how long to wait, and what it sounds like. + * + *

Both are written straight through on change rather than on leaving the screen. The + * service re-reads them on its own schedule, so a value that is only in memory here is one + * the thing that uses it never sees. + */ + private void setupLeftBehindSettings() { + final Slider seconds = this.findViewById(R.id.settings_left_behind_seconds); + final TextView label = this.findViewById(R.id.settings_left_behind_seconds_label); + + final int configured = this.currentSettings.resolveLeftBehindAfterSeconds(); + seconds.setValue(configured); + label.setText(this.getString(R.string.left_behind_seconds_label, configured)); + + seconds.addOnChangeListener((slider, value, fromUser) -> { + final int chosen = Math.round(value); + label.setText(this.getString(R.string.left_behind_seconds_label, chosen)); + + if (!fromUser) { + return; + } + + this.currentSettings.setLeftBehindAfterSeconds(chosen); + this.persistCurrentSettings("left-behind delay"); + }); + + this.showChosenAlarmSound(); + this.findViewById(R.id.settings_left_behind_sound_row) + .setOnClickListener(v -> this.pickAlarmSound()); + } + + /** Writes the current sound's own name under the row, so the setting says what it does. */ + private void showChosenAlarmSound() { + final TextView value = this.findViewById(R.id.settings_left_behind_sound_value); + final String stored = this.currentSettings.getLeftBehindSoundUri(); + + if (stored == null || stored.isEmpty()) { + value.setText(R.string.left_behind_sound_default); + return; + } + + // A sound can be deleted, or live on a volume that is not mounted, long after it was + // chosen. Naming it "default" then is honest: that is what will actually play. + final Ringtone ringtone = RingtoneManager.getRingtone(this, Uri.parse(stored)); + final String title = ringtone == null ? null : ringtone.getTitle(this); + + value.setText(title == null || title.isEmpty() + ? this.getString(R.string.left_behind_sound_default) : title); + } + + /** Opens the system ringtone picker, starting from whatever is set now. */ + private void pickAlarmSound() { + final String stored = this.currentSettings.getLeftBehindSoundUri(); + + final Intent picker = new Intent(RingtoneManager.ACTION_RINGTONE_PICKER) + .putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_ALARM) + .putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, + this.getString(R.string.left_behind_sound)) + // Offering silence here would be a way to turn the alert off that leaves the + // switch reading as on, so the picker does not show it. + .putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, false) + .putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true) + .putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)) + .putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, + stored == null || stored.isEmpty() ? null : Uri.parse(stored)); + + this.alarmSoundPicker.launch(picker); + } + + /** + * The chosen alarm sound coming back from the system picker. + * + *

A null URI is the "Default" entry rather than a cancelled pick - the picker is launched + * without a silent option - and is stored as empty, which is what the service reads as "the + * system default alarm". + */ + private final ActivityResultLauncher alarmSoundPicker = + registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() != RESULT_OK || result.getData() == null) { + return; + } + + final Uri picked = result.getData().getParcelableExtra( + RingtoneManager.EXTRA_RINGTONE_PICKED_URI); + + this.currentSettings.setLeftBehindSoundUri( + picked == null ? "" : picked.toString()); + this.persistCurrentSettings("alarm sound"); + this.showChosenAlarmSound(); + }); + + /** Stores the settings object as it stands, logging rather than interrupting on failure. */ + private void persistCurrentSettings(final String what) { + this.settingsRepository.storeUserSettings(this.currentSettings) + .subscribeOn(Schedulers.io()) + .subscribe(() -> Log.i(TAG, "Stored the " + what), + error -> Log.w(TAG, "Could not store the " + what, error)); + } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java new file mode 100644 index 00000000..c7446537 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java @@ -0,0 +1,40 @@ +package dev.wander.android.opentagviewer.ble; + +import android.content.Context; + +import io.reactivex.rxjava3.core.Observable; + +/** + * Plays an owned accessory's sound directly over Bluetooth, without going through Apple's Find + * My network - the same thing Find My itself does when a tag is close enough to reach. + * + *

Behind an interface for the reason every Chaquopy/hardware dependency in this app is: the + * real implementation needs Bluetooth radio and a nearby accessory, neither of which a test can + * arrange - see {@code AppDependencies}. + */ +public interface AccessorySoundTrigger { + + /** + * @param context used for the Bluetooth system service and permission checks. + * @param accessoryJson the persisted {@code OwnedBeacon.accessoryJson} for this beacon. + * @return an {@link Observable} of {@link BleSoundTriggerUpdate}s - progress phases + * (scanning, connecting, triggering) followed by exactly one terminal + * {@link BleSoundTriggerPhase#DONE} carrying the {@link BleSoundTriggerResult}, then + * completes. Never errors - every failure this can hit (no permission, not in range, + * connect/write failure) is a status on the DONE result, not an exception, so a caller only + * ever needs {@code subscribe} with one lambda. The progress items exist so a caller can show + * "connecting..." instead of nothing for however long the handshake takes. + */ + Observable playSound(Context context, String accessoryJson); + + /** + * Repeats {@link #playSound} - scan, trigger (or fail), pause, scan again - for as long as + * the returned {@link Observable} stays subscribed. For walking toward a tag by ear: a + * single {@link #playSound} only ever gets one chance to be in range at the moment it scans. + * + *

Never errors, same as {@link #playSound} - each item is a progress phase or a DONE + * outcome, not a terminal signal, so one failed cycle (e.g. briefly out of range) does not + * end the loop. Dispose the subscription to stop. + */ + Observable playSoundContinuously(Context context, String accessoryJson); +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessoryMatcher.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessoryMatcher.java new file mode 100644 index 00000000..50edccb6 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessoryMatcher.java @@ -0,0 +1,35 @@ +package dev.wander.android.opentagviewer.ble; + +import java.util.Locale; +import java.util.Set; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Whether a scanned BLE address is one of an accessory's currently-expected MAC addresses. + * + *

Pulled out as a pure function, deliberately not Android or Chaquopy: both sides of this + * comparison are stated to be uppercase already - {@code BluetoothDevice.getAddress()} by + * Android's own contract, {@code KeyPair.mac_address} by FindMy.py's implementation - but a + * platform or library changing that quietly would fail silently as "tag never found" rather than + * loudly, so this normalises rather than trusting it. Kept free of both dependencies so this, + * the part that actually decides a match, is the part with a test that runs on plain JVM. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class BleAccessoryMatcher { + + public static boolean matches(final String scannedDeviceAddress, final Set candidateMacs) { + if (scannedDeviceAddress == null || candidateMacs.isEmpty()) { + return false; + } + + final String normalised = scannedDeviceAddress.toUpperCase(Locale.ROOT); + for (final String candidate : candidateMacs) { + if (candidate != null && candidate.toUpperCase(Locale.ROOT).equals(normalised)) { + return true; + } + } + return false; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java new file mode 100644 index 00000000..3c3df140 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java @@ -0,0 +1,240 @@ +package dev.wander.android.opentagviewer.ble; + +import android.bluetooth.BluetoothDevice; +import android.content.Context; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import dev.wander.android.opentagviewer.python.AccessoryMacResolver; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * The real {@link AccessorySoundTrigger}: resolves candidate MACs through Python, scans for one + * of them, and triggers the accessory's GATT sound service once found. + * + *

What this has actually been run against, and what it has not. The GATT protocol + * logic in {@link BleGattSoundTrigger} is a port of a Kotlin prototype (a personal companion + * project, TrackerHunter) that was exercised against real AirTags over BLE. This class's own + * orchestration - the permission gate, the retry count, the continuous-repeat wiring - has a + * JVM test suite ({@code BleAccessorySoundTriggerTest}) exercising it against fakes, but has not + * been run end-to-end on a device by whoever wrote it. Per AGENTS.md rule 2: say so rather than + * claim otherwise. + * + *

Why the BLE pieces are constructor-injected rather than static calls to + * {@link NearbyAccessoryScanner}/{@link BleGattSoundTrigger}/{@link BlePermissions}. Those + * three need Android hardware to run for real, which a JVM test cannot arrange - same reasoning + * as {@code AppDependencies} injecting {@code HardwareDescriber} instead of calling Chaquopy + * directly. This class's own logic (which status is worth retrying, how many times, what a + * missing permission or an empty candidate set short-circuits to) is what the seams exist to + * test, without needing Bluetooth or a device to do it. + * + *

Why {@code } at all, rather than just {@code BluetoothDevice}. A found device is + * opaque to every line of logic in this class - it is looked at nowhere, only handed from the + * scanner seam to the GATT seam. Fixing it to {@code BluetoothDevice} would mean the test suite + * needs one, and the real SDK class has no public constructor and no test double in this project + * (no Robolectric here - see AGENTS.md's JVM-vs-instrumented split). A type parameter lets the + * test use a plain {@code String} as a stand-in and this class stays none the wiser; production + * code fixes {@code D} to {@code BluetoothDevice} once, in the public constructor's inferred type. + */ +public class BleAccessorySoundTrigger implements AccessorySoundTrigger { + + /** Whether the required Bluetooth permission(s) are granted. Real: {@link BlePermissions#granted}. */ + interface PermissionCheck { + boolean granted(Context context); + } + + /** Scans for one of the candidate MACs. Real: {@link NearbyAccessoryScanner#findNearby}. */ + interface Scanner { + Single findNearby(Context context, Set candidateMacs, long timeoutMs); + } + + /** Runs the GATT handshake against a found device. Real: {@link BleGattSoundTrigger#trigger}. */ + interface GattTrigger { + Observable trigger(Context context, D device); + } + + /** + * Reads the BLE address off a found device. Real: {@code BluetoothDevice::getAddress}. + * + *

A seam for the same reason {@code D} is a type parameter at all: this class never looks + * at a device except to say which candidate it was, and requiring a real + * {@code BluetoothDevice} to answer that would put the whole class back on a device. + */ + interface AddressOf { + String address(D device); + } + + /** + * How long to scan before giving up. Long enough that an AirTag's ~1 second-ish advertising + * interval is seen several times over, short enough that tapping the button and walking away + * does not leave a scan running indefinitely. + */ + private static final long SCAN_TIMEOUT_MS = 15_000L; + + /** + * How many GATT attempts one found device gets before this counts as failed and the caller + * decides what to do next (for {@link #playSoundContinuously}, that means re-scanning). + * + *

BLE connection setup is failure-prone in ways that mean nothing about the accessory + * itself - a stale radio state, a busy Bluetooth stack, a connection that timed out for no + * reason a retry wouldn't also hit. Only worth it for {@link BleSoundTriggerStatus#FAILED}: + * {@link BleSoundTriggerStatus#NO_SOUND_SERVICE} means the connection worked and nothing + * this app recognises was there, which retrying the same device will not change. + */ + private static final int GATT_ATTEMPTS = 3; + + /** Pause between attempts, so a retry isn't fired at a radio still settling from the + * previous attempt's disconnect. */ + private static final long GATT_RETRY_DELAY_MS = 800L; + + /** + * How long {@link #playSoundContinuously} waits after one attempt (found or not) before the + * next. Short enough to feel responsive while walking toward a tag; long enough that a + * successful AirTag chirp (a few seconds) has time to finish before the next scan starts. + */ + private static final long CONTINUOUS_PING_PAUSE_MS = 4_000L; + + private final AccessoryMacResolver macResolver; + + private final AddressOf addressOf; + private final PermissionCheck permissionCheck; + private final Scanner scanner; + private final GattTrigger gattTrigger; + private final int gattAttempts; + private final long gattRetryDelayMs; + private final long continuousPingPauseMs; + + /** + * The real thing: {@code D} fixed to {@link BluetoothDevice}, and every seam wired to its + * real Android implementation. A static factory rather than a public constructor because a + * plain constructor on a generic class cannot pin {@code D} for its caller - the seam method + * references here are concretely {@code BluetoothDevice}-typed, so the constructor itself + * has to be the one that says so. + */ + public static BleAccessorySoundTrigger forRealBluetooth( + final AccessoryMacResolver macResolver) { + return new BleAccessorySoundTrigger<>(macResolver, BlePermissions::granted, + NearbyAccessoryScanner::findNearby, BleGattSoundTrigger::trigger, + BluetoothDevice::getAddress, + GATT_ATTEMPTS, GATT_RETRY_DELAY_MS, CONTINUOUS_PING_PAUSE_MS); + } + + /** Package-private: only {@code BleAccessorySoundTriggerTest} constructs one of these with fakes. */ + BleAccessorySoundTrigger( + final AccessoryMacResolver macResolver, + final PermissionCheck permissionCheck, + final Scanner scanner, + final GattTrigger gattTrigger, + final AddressOf addressOf, + final int gattAttempts, + final long gattRetryDelayMs, + final long continuousPingPauseMs) { + this.macResolver = macResolver; + this.permissionCheck = permissionCheck; + this.scanner = scanner; + this.gattTrigger = gattTrigger; + this.addressOf = addressOf; + this.gattAttempts = gattAttempts; + this.gattRetryDelayMs = gattRetryDelayMs; + this.continuousPingPauseMs = continuousPingPauseMs; + } + + @Override + public Observable playSound(final Context context, final String accessoryJson) { + return Observable.defer(() -> { + if (!this.permissionCheck.granted(context)) { + return Observable.just(BleSoundTriggerUpdate.done(new BleSoundTriggerResult( + BleSoundTriggerStatus.MISSING_PERMISSION, null, + "Bluetooth scan/connect permission not granted", null))); + } + + // Blocking - starts a Python interpreter. Safe here because the whole chain is + // subscribed on Schedulers.io() below, same as PythonAppleService's calls. + // Resolved once per attempt, not once per advertisement: the derivation and the + // trip across the Chaquopy bridge are the expensive parts, and the candidate set + // only moves when the fifteen-minute key interval ticks. + final Map candidates = + this.macResolver.currentMacAddresses(accessoryJson); + + // **Null and empty mean the same thing here, and null must not be dereferenced.** + // The interface permits null for an accessory the resolver cannot read, and for + // one whose candidate window is too wide to be worth deriving - an owner's own + // Apple device reaches this code through "show my own Apple devices" and is exactly + // that, since a phone has no rolling-key alignment. Either way there is no address + // to scan for, which is what NO_CANDIDATE_MACS already says. + // + // Latent rather than observed: the Chaquopy implementation maps Python's None to an + // empty map, so no build has thrown here - but the signature permits null, and a + // throw inside this chain would surface as an error where "cannot resolve this one" + // is the honest answer. + if (candidates == null || candidates.isEmpty()) { + return Observable.just(BleSoundTriggerUpdate.done(new BleSoundTriggerResult( + BleSoundTriggerStatus.NO_CANDIDATE_MACS, null, + "Could not resolve a current MAC address for this accessory", null))); + } + + return Observable.just(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.SCANNING)) + .concatWith(this.scanner + .findNearby(context, candidates.keySet(), SCAN_TIMEOUT_MS) + .toObservable() + .flatMap(device -> { + // The address itself, not the index currentMacAddresses paired + // it with - see BleSoundTriggerResult on why only the caller + // (through Python, which alone can tell a primary key's index + // from a secondary one's) may turn this into an alignment write. + final String matchedMac = this.addressOf.address(device); + + return this.triggerWithRetry(context, device, this.gattAttempts) + .map(update -> update.withMatchedMac(matchedMac)); + }) + .onErrorReturn(BleAccessorySoundTrigger::asDoneUpdate)); + }).subscribeOn(Schedulers.io()); + } + + /** + * {@link GattTrigger#trigger}, retried up to {@code attemptsLeft} times as long as each + * failure is {@link BleSoundTriggerStatus#FAILED} - see {@link #GATT_ATTEMPTS}. Only the + * final attempt's DONE reaches the caller; earlier failed attempts are swallowed in favour + * of a fresh {@link BleSoundTriggerPhase#CONNECTING} and another try. + */ + private Observable triggerWithRetry( + final Context context, final D device, final int attemptsLeft) { + return this.gattTrigger.trigger(context, device) + .concatMap(update -> { + final boolean isRetryableFailure = update.getPhase() == BleSoundTriggerPhase.DONE + && update.getResult().getStatus() == BleSoundTriggerStatus.FAILED; + if (!isRetryableFailure || attemptsLeft <= 1) { + return Observable.just(update); + } + + return Observable.timer(this.gattRetryDelayMs, TimeUnit.MILLISECONDS) + .flatMap(tick -> Observable + .just(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.CONNECTING)) + .concatWith(Observable.defer(() -> + this.triggerWithRetry(context, device, attemptsLeft - 1)))); + }); + } + + @Override + public Observable playSoundContinuously( + final Context context, final String accessoryJson) { + // playSound completes after its DONE item; repeatWhen re-subscribes it once the delayed + // completion signal fires, which is what turns "do it once" into "do it again after a + // pause", forever, until the subscriber disposes. + return this.playSound(context, accessoryJson) + .repeatWhen(completed -> completed.delay(this.continuousPingPauseMs, TimeUnit.MILLISECONDS)); + } + + private static BleSoundTriggerUpdate asDoneUpdate(final Throwable error) { + if (error instanceof NearbyAccessoryScanner.NotNearbyException) { + return BleSoundTriggerUpdate.done(new BleSoundTriggerResult( + BleSoundTriggerStatus.NOT_NEARBY, null, error.getMessage(), null)); + } + return BleSoundTriggerUpdate.done(new BleSoundTriggerResult( + BleSoundTriggerStatus.FAILED, null, String.valueOf(error.getMessage()), null)); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java new file mode 100644 index 00000000..975fc046 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java @@ -0,0 +1,242 @@ +package dev.wander.android.opentagviewer.ble; + +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothGattService; +import android.bluetooth.BluetoothProfile; +import android.content.Context; +import android.os.Build; +import android.util.Log; + +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.reactivex.rxjava3.core.Observable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Play-sound GATT trigger for Find My / DULT-compatible accessories. + * + *

Tries three known protocols, in the same priority order as AirGuard + * (https://github.com/seemoo-lab/AirGuard, Apache-2.0) uses - the UUIDs and opcodes are verified + * against that project's current source ({@code database/models/device/types/AppleFindMy.kt} for + * the DULT and AirTag-specific paths, {@code GoogleFindMyNetwork.kt} for confirming Google's own + * sound service is byte-for-byte the same one DULT defines). + * + *

Ported from a Kotlin prototype (a personal companion project, TrackerHunter) that already + * exercised this against real AirTags; this is the same state machine expressed as a Java + * {@link Observable} instead of a coroutine, to match this app's RxJava3 convention. See + * {@code BleAccessorySoundTrigger} for the honesty about what has and has not actually been run. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class BleGattSoundTrigger { + private static final String TAG = BleGattSoundTrigger.class.getSimpleName(); + + private static final UUID DULT_SERVICE = + UUID.fromString("15190001-12F4-C226-88ED-2AC5579F2A85"); + private static final UUID DULT_CHARACTERISTIC = + UUID.fromString("8E0C0001-1D68-FB92-BF61-48377421680E"); + private static final byte[] DULT_START_OPCODE = {0x00, 0x03}; + + private static final String FINDMY_SERVICE_SHORT = "fd44"; + private static final UUID FINDMY_CHARACTERISTIC = + UUID.fromString("4F860003-943B-49EF-BED4-2F730304427A"); + private static final byte[] FINDMY_START_OPCODE = {0x01, 0x00, 0x03}; + + private static final UUID AIRTAG_SERVICE = + UUID.fromString("7DFC9000-7D1C-4951-86AA-8D9728F8D66C"); + private static final UUID AIRTAG_CHARACTERISTIC = + UUID.fromString("7DFC9001-7D1C-4951-86AA-8D9728F8D66C"); + private static final byte[] AIRTAG_PLAY_VALUE = {(byte) 0xAF}; + + private static final UUID CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); + + /** + * Connects to {@code device}, tries all three protocols, and completes once the first + * matching one's start command has been written (or all three failed). Does not wait for the + * sound to finish playing. + * + *

Emits a {@link BleSoundTriggerPhase#CONNECTING} update immediately, a + * {@link BleSoundTriggerPhase#TRIGGERING} one once a matching sound service is found, then + * exactly one {@link BleSoundTriggerPhase#DONE} - so a caller can show "connecting..." + * instead of nothing for however long the handshake takes. Disposing before it completes + * disconnects and closes the GATT connection rather than leaving it open in the background. + */ + @SuppressLint("MissingPermission") + public static Observable trigger( + final Context context, final BluetoothDevice device) { + return Observable.create(emitter -> { + final AtomicBoolean resumed = new AtomicBoolean(false); + final BluetoothGatt[] gattRef = new BluetoothGatt[1]; + + final BluetoothGattCallback callback = new BluetoothGattCallback() { + private BluetoothGattCharacteristic pendingCharacteristic; + private byte[] pendingOpcode; + private String pendingProtocolName; + + private void finish(final BleSoundTriggerResult result) { + // Guards against a callback landing twice (e.g. a disconnect that follows a + // successful write) - only the first one reaches the emitter, matching the + // "exactly one DONE, then complete" contract. + if (!resumed.compareAndSet(false, true)) return; + if (emitter.isDisposed()) return; + emitter.onNext(BleSoundTriggerUpdate.done(result)); + emitter.onComplete(); + } + + @Override + public void onConnectionStateChange( + final BluetoothGatt gatt, final int status, final int newState) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + Log.d(TAG, "Connected to " + device.getAddress() + + ", discovering services"); + gatt.discoverServices(); + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + Log.d(TAG, "Disconnected from " + device.getAddress()); + gatt.close(); + // A disconnect after a successful write is the normal AirTag completion + // signal, not a failure - finish() already resumed by then, so this call + // is a no-op (see the AtomicBoolean guard above). + finish(new BleSoundTriggerResult(BleSoundTriggerStatus.FAILED, null, + "Connection closed before a sound command was sent")); + } + } + + @Override + public void onServicesDiscovered(final BluetoothGatt gatt, final int status) { + if (status != BluetoothGatt.GATT_SUCCESS) { + finish(new BleSoundTriggerResult(BleSoundTriggerStatus.FAILED, null, + "Service discovery failed (status=" + status + ")")); + gatt.disconnect(); + return; + } + + final BluetoothGattCharacteristic dult = characteristicOf(gatt, DULT_SERVICE, DULT_CHARACTERISTIC); + final BluetoothGattCharacteristic findMy = findMyCharacteristic(gatt); + final BluetoothGattCharacteristic airtag = characteristicOf(gatt, AIRTAG_SERVICE, AIRTAG_CHARACTERISTIC); + + if (dult != null || findMy != null || airtag != null) { + if (!emitter.isDisposed()) { + emitter.onNext(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.TRIGGERING)); + } + } + + if (dult != null) { + enableNotifyThenWrite(gatt, dult, DULT_START_OPCODE, "DULT"); + } else if (findMy != null) { + enableNotifyThenWrite(gatt, findMy, FINDMY_START_OPCODE, "FindMy (fd44)"); + } else if (airtag != null) { + pendingProtocolName = "AirTag"; + writeCharacteristicCompat(gatt, airtag, AIRTAG_PLAY_VALUE); + } else { + finish(new BleSoundTriggerResult(BleSoundTriggerStatus.NO_SOUND_SERVICE, null, + "No known sound service found (checked DULT, FindMy, AirTag)")); + gatt.disconnect(); + } + } + + private void enableNotifyThenWrite( + final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic, + final byte[] opcode, final String protocolName) { + pendingProtocolName = protocolName; + pendingCharacteristic = characteristic; + pendingOpcode = opcode; + + gatt.setCharacteristicNotification(characteristic, true); + final BluetoothGattDescriptor descriptor = characteristic.getDescriptor(CCCD); + if (descriptor == null) { + // No CCCD - just write directly, matching the AirTag path. + writeCharacteristicCompat(gatt, characteristic, opcode); + return; + } + writeDescriptorCompat(gatt, descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); + } + + @Override + public void onDescriptorWrite( + final BluetoothGatt gatt, final BluetoothGattDescriptor descriptor, final int status) { + if (pendingCharacteristic == null || pendingOpcode == null) return; + Log.d(TAG, "CCCD write status=" + status + ", writing start opcode to " + + pendingCharacteristic.getUuid()); + writeCharacteristicCompat(gatt, pendingCharacteristic, pendingOpcode); + } + + @Override + public void onCharacteristicWrite( + final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic, + final int status) { + if (status == BluetoothGatt.GATT_SUCCESS) { + final String protocol = pendingProtocolName == null ? "unknown" : pendingProtocolName; + Log.i(TAG, "Sound triggered via " + protocol + " on " + device.getAddress()); + finish(new BleSoundTriggerResult(BleSoundTriggerStatus.SUCCESS, protocol, null)); + // AirTag disconnects on its own once the sound finishes; DULT/FindMy + // don't, so force it here - gives every trigger() call a bounded + // lifetime. + if (!"AirTag".equals(protocol)) { + gatt.disconnect(); + } + } else { + finish(new BleSoundTriggerResult(BleSoundTriggerStatus.FAILED, null, + "Write failed (status=" + status + ")")); + gatt.disconnect(); + } + } + }; + + emitter.onNext(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.CONNECTING)); + gattRef[0] = device.connectGatt(context, false, callback); + + emitter.setCancellable(() -> { + if (gattRef[0] != null) { + gattRef[0].disconnect(); + gattRef[0].close(); + } + }); + }); + } + + private static BluetoothGattCharacteristic characteristicOf( + final BluetoothGatt gatt, final UUID service, final UUID characteristic) { + final BluetoothGattService svc = gatt.getService(service); + return svc == null ? null : svc.getCharacteristic(characteristic); + } + + /** The FindMy/DULT service UUID is vendor-suffixed; matched on its distinguishing prefix. */ + private static BluetoothGattCharacteristic findMyCharacteristic(final BluetoothGatt gatt) { + for (final BluetoothGattService service : gatt.getServices()) { + if (service.getUuid().toString().toLowerCase(Locale.ROOT).contains(FINDMY_SERVICE_SHORT)) { + return service.getCharacteristic(FINDMY_CHARACTERISTIC); + } + } + return null; + } + + @SuppressLint("MissingPermission") + private static void writeCharacteristicCompat( + final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic, final byte[] value) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeCharacteristic(characteristic, value, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT); + } else { + characteristic.setValue(value); + characteristic.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT); + gatt.writeCharacteristic(characteristic); + } + } + + @SuppressLint("MissingPermission") + private static void writeDescriptorCompat( + final BluetoothGatt gatt, final BluetoothGattDescriptor descriptor, final byte[] value) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeDescriptor(descriptor, value); + } else { + descriptor.setValue(value); + gatt.writeDescriptor(descriptor); + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BlePermissions.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BlePermissions.java new file mode 100644 index 00000000..34ad00f1 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BlePermissions.java @@ -0,0 +1,50 @@ +package dev.wander.android.opentagviewer.ble; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; + +import androidx.core.content.ContextCompat; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * What BLE scanning and GATT connection need at runtime, in one place. + * + *

Shared between the activity that requests these permissions and + * {@link BleAccessorySoundTrigger}, which depends on them being granted, so the two cannot + * silently disagree about what "enough" means - the same reasoning as AGENTS.md's rule on + * putting a provider decision behind one abstraction rather than branching in more than one + * place. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class BlePermissions { + + /** + * Android 12+ (API 31) needs {@code BLUETOOTH_SCAN}/{@code BLUETOOTH_CONNECT}; below that, + * BLE scanning is gated on location instead. Both are already declared unconditionally in + * the manifest, for the map feature - this only asks whether they are granted *yet*. + */ + public static String[] required() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return new String[]{Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT}; + } + return new String[]{Manifest.permission.ACCESS_FINE_LOCATION}; + } + + public static boolean granted(final Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return isGranted(context, Manifest.permission.BLUETOOTH_SCAN) + && isGranted(context, Manifest.permission.BLUETOOTH_CONNECT); + } + return isGranted(context, Manifest.permission.ACCESS_FINE_LOCATION) + || isGranted(context, Manifest.permission.ACCESS_COARSE_LOCATION); + } + + private static boolean isGranted(final Context context, final String permission) { + return ContextCompat.checkSelfPermission(context, permission) + == PackageManager.PERMISSION_GRANTED; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerPhase.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerPhase.java new file mode 100644 index 00000000..c5fa2c8b --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerPhase.java @@ -0,0 +1,16 @@ +package dev.wander.android.opentagviewer.ble; + +/** Where one {@link AccessorySoundTrigger#playSound} attempt currently is. */ +public enum BleSoundTriggerPhase { + /** Scanning for one of the accessory's candidate BLE addresses. */ + SCANNING, + + /** Found it; opening a GATT connection. */ + CONNECTING, + + /** Connected; writing the play-sound characteristic. */ + TRIGGERING, + + /** Finished - see the accompanying {@link BleSoundTriggerResult}. */ + DONE, +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java new file mode 100644 index 00000000..3909e79b --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java @@ -0,0 +1,53 @@ +package dev.wander.android.opentagviewer.ble; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** Outcome of one {@link BleAccessorySoundTrigger#playSound} attempt. */ +@AllArgsConstructor +@Getter +public class BleSoundTriggerResult { + private final BleSoundTriggerStatus status; + + /** Which GATT protocol answered - "DULT", "FindMy (fd44)" or "AirTag" - null unless SUCCESS. */ + private final String protocol; + + /** Detail for logs, in whatever language the underlying failure happened to arrive in. */ + private final String message; + + /** + * The BLE address the accessory was found advertising as, or null if it was not found. + * + *

Reported rather than acted on, deliberately. A sighting can pin the alignment, + * which is what keeps the next scan cheap - but persisting it means Python and the database, + * and this package has neither. The caller hands it to + * {@code BeaconRepository#recordAccessorySighting}, which is where every other + * accessory-state write already lives. + * + *

The address rather than the key index {@code currentMacAddresses} paired it with. + * That index is only trustworthy when the address came from a primary key - a secondary + * key's index is a lower bound, not the true one - and this package has no way to tell the + * two apart; only {@code main.py:recordAccessorySeen} can, by re-deriving the key at this + * address and checking its type. Passing the raw index on would risk the caller trusting an + * index this package cannot vouch for. + * + *

Set whenever the scan matched, including when the GATT handshake then failed: the + * tag really was there, and that is true regardless of whether it made a noise. + */ + private final String matchedMac; + + /** + * An outcome from a stage that cannot know which candidate answered, which is every stage + * but the scan. + * + *

{@link BleGattSoundTrigger} is handed a device and told to talk to it; which candidate + * that device was is not its business and not in its scope. It reports the outcome, and + * {@link BleAccessorySoundTrigger#playSound} - the one place that holds both the candidate + * set and the device - attaches the address afterwards via + * {@link BleSoundTriggerUpdate#withMatchedMac}. + */ + public BleSoundTriggerResult( + final BleSoundTriggerStatus status, final String protocol, final String message) { + this(status, protocol, message, null); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerStatus.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerStatus.java new file mode 100644 index 00000000..96bcb944 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerStatus.java @@ -0,0 +1,22 @@ +package dev.wander.android.opentagviewer.ble; + +/** How a {@link BleAccessorySoundTrigger#playSound} attempt ended. */ +public enum BleSoundTriggerStatus { + /** The start command was written; the accessory is (or was) playing its sound. */ + SUCCESS, + + /** The accessory's resolved candidate MAC address set was empty; nothing to scan for. */ + NO_CANDIDATE_MACS, + + /** The scan window ended without seeing any of the candidate MACs advertise. */ + NOT_NEARBY, + + /** Connected, but none of the known GATT sound services (DULT, FindMy, AirTag) were found. */ + NO_SOUND_SERVICE, + + /** A required runtime permission (scan or connect) is not granted. */ + MISSING_PERMISSION, + + /** Bluetooth is off, or connecting/writing otherwise failed. */ + FAILED, +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerUpdate.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerUpdate.java new file mode 100644 index 00000000..5746350b --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerUpdate.java @@ -0,0 +1,45 @@ +package dev.wander.android.opentagviewer.ble; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * One item of an {@link AccessorySoundTrigger#playSound} stream: either a progress phase with no + * result yet, or the terminal {@link BleSoundTriggerPhase#DONE} carrying the outcome. + * + *

A stream rather than a single terminal value so a caller can show "found, connecting..." + * instead of going silent for however long the GATT handshake takes - which otherwise reads as + * nothing happening, especially the first time someone uses this. + */ +@AllArgsConstructor +@Getter +public class BleSoundTriggerUpdate { + private final BleSoundTriggerPhase phase; + + /** Non-null if and only if {@link #phase} is {@link BleSoundTriggerPhase#DONE}. */ + private final BleSoundTriggerResult result; + + public static BleSoundTriggerUpdate progress(final BleSoundTriggerPhase phase) { + return new BleSoundTriggerUpdate(phase, null); + } + + public static BleSoundTriggerUpdate done(final BleSoundTriggerResult result) { + return new BleSoundTriggerUpdate(BleSoundTriggerPhase.DONE, result); + } + + /** + * The same update with the address the scan matched, if this is the terminal one. + * + *

Applied after the fact because the sighting happens at the end of the scan and the + * outcome only exists at the end of the GATT exchange - which may be several retries later, + * and may fail. Attaching it here means every DONE that followed a real sighting carries it, + * without the retry logic having to know the address exists. + */ + public BleSoundTriggerUpdate withMatchedMac(final String mac) { + if (this.phase != BleSoundTriggerPhase.DONE || mac == null) { + return this; + } + return done(new BleSoundTriggerResult(this.result.getStatus(), this.result.getProtocol(), + this.result.getMessage(), mac)); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/DerivedAddressStore.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/DerivedAddressStore.java new file mode 100644 index 00000000..4ac7f840 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/DerivedAddressStore.java @@ -0,0 +1,252 @@ +package dev.wander.android.opentagviewer.ble; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Keeps the addresses derived for a tag, so they are derived once rather than once per app start. + * + *

Why this is safe to keep at all. An address is a pure function of the accessory's + * keys and a key index, so an address derived today is still one that accessory can advertise + * tomorrow. Nothing here can go stale or wrong; it can only be incomplete. That is what lets a + * wider range simply be written over a narrower one, with no event ever invalidating what is + * already there. In particular the alignment moving does not, because alignment decides which + * part of the range is worth watching, not what the range contains. + * + *

Why it is worth keeping. Deriving costs about two to three seconds per thousand + * indices on an idle phone, and ten times that while the app is starting up and competing with + * itself, which is exactly when the index was being rebuilt. Paying it once per tag instead of + * once per launch is what makes a range wide enough for a long-missing tag affordable at all. + * + *

The index is kept for primary keys and dropped for secondary ones. A primary key + * occurs at exactly one index and stays there, so it is worth keeping: an address recovered from + * this file arrives with an exact hint, and confirming an alignment then costs three key + * derivations rather than a search of a 48 hour window - measured at 0.02 seconds against about + * one. A secondary key is different. It covers 96 consecutive indices and is reported at whatever + * index the deriving call's own range began at, so the same address comes back against a + * different number depending on how the work was split. That is an artefact of the split rather + * than a fact about the tag, so it is stored as unknown and read back as no hint at all, which + * costs the wide check that every check cost before hints existed. + * + *

A cache in the file sense too: losing it costs time and never correctness, so it lives in + * the app's files directory rather than in the database, and may be deleted at any point. + */ +public final class DerivedAddressStore { + private static final String TAG = DerivedAddressStore.class.getSimpleName(); + + /** Bumped when the layout below changes, so an older file is discarded rather than misread. */ + private static final int FORMAT_VERSION = 2; + + /** + * The stored index for an address whose index means nothing. + * + *

Matches {@code main._INDEX_UNKNOWN}. A secondary key is reported at whatever index the + * deriving call's range began at, so it is not a fact about the tag and must not be read + * back as one. + */ + private static final int INDEX_UNKNOWN = -1; + + private static final String DIRECTORY = "derived-addresses"; + + private static final String SUFFIX = ".bin"; + + private final File directory; + + public DerivedAddressStore(final File filesDir) { + this.directory = new File(filesDir, DIRECTORY); + } + + /** What was derived for one tag, and the index range it was derived over. */ + public static final class Derived { + private final int lo; + private final int hi; + private final Map addresses; + + public Derived(final int lo, final int hi, final Map addresses) { + this.lo = lo; + this.hi = hi; + this.addresses = addresses; + } + + public int getLo() { + return this.lo; + } + + public int getHi() { + return this.hi; + } + + /** Address to the index it was derived at, or null where that index means nothing. */ + public Map getAddresses() { + return this.addresses; + } + + /** Whether this already holds everything an inclusive range would produce. */ + public boolean covers(final int wantedLo, final int wantedHi) { + return this.lo <= wantedLo && wantedHi <= this.hi; + } + } + + /** + * What has been derived for this tag, or null if nothing has. + * + *

Never throws for a damaged or truncated file. A cache that cannot be read is a cache + * that has not been written yet, and the caller then derives from scratch exactly as it + * would have done anyway. + */ + @Nullable + public Derived load(final String beaconId) { + final File file = this.fileFor(beaconId); + if (!file.isFile()) { + return null; + } + + try (DataInputStream in = new DataInputStream(new FileInputStream(file))) { + if (in.readInt() != FORMAT_VERSION) { + Log.d(TAG, "Discarding a derived-address file written by another version"); + return null; + } + + final int lo = in.readInt(); + final int hi = in.readInt(); + final int count = in.readInt(); + + final Map addresses = new HashMap<>(Math.max(16, count * 2)); + final byte[] mac = new byte[6]; + for (int i = 0; i < count; i++) { + in.readFully(mac); + final int index = in.readInt(); + addresses.put(formatMac(mac), index == INDEX_UNKNOWN ? null : index); + } + + return new Derived(lo, hi, addresses); + } catch (final IOException | RuntimeException unreadable) { + // Truncated by a kill mid-write, or written by a build that packed it differently. + Log.d(TAG, "Could not read the derived addresses for beaconId=" + beaconId + + "; deriving them again", unreadable); + return null; + } + } + + /** + * Writes what has been derived for this tag, replacing whatever was there. + * + *

Through a temporary file and a rename, so being killed halfway leaves the previous copy + * rather than a shorter one. A truncated file would read back as a narrower covered range + * than was actually derived, and the missing part would be derived again on every launch + * with nothing ever reporting that it had been lost. + */ + public void save(final String beaconId, final int lo, final int hi, + final Map addresses) { + if (!this.directory.isDirectory() && !this.directory.mkdirs()) { + Log.w(TAG, "Could not create " + this.directory + "; not keeping derived addresses"); + return; + } + + final File target = this.fileFor(beaconId); + final File temporary = new File(target.getPath() + ".tmp"); + + try (DataOutputStream out = new DataOutputStream(new FileOutputStream(temporary))) { + out.writeInt(FORMAT_VERSION); + out.writeInt(lo); + out.writeInt(hi); + out.writeInt(addresses.size()); + + for (final Map.Entry entry : addresses.entrySet()) { + final byte[] mac = parseMac(entry.getKey()); + if (mac != null) { + out.write(mac); + out.writeInt(entry.getValue() == null ? INDEX_UNKNOWN : entry.getValue()); + } + } + } catch (final IOException couldNotWrite) { + Log.w(TAG, "Could not write the derived addresses for beaconId=" + beaconId, + couldNotWrite); + temporary.delete(); + return; + } + + if (!temporary.renameTo(target) && (!target.delete() || !temporary.renameTo(target))) { + Log.w(TAG, "Could not replace the derived addresses for beaconId=" + beaconId); + temporary.delete(); + } + } + + /** Forgets what was derived for tags the user no longer has. */ + public void forgetAllExcept(final Set beaconIds) { + final File[] files = this.directory.listFiles(); + if (files == null) { + return; + } + + for (final File file : files) { + final String name = file.getName(); + if (!name.endsWith(SUFFIX)) { + continue; + } + + final String stored = name.substring(0, name.length() - SUFFIX.length()); + + boolean wanted = false; + for (final String beaconId : beaconIds) { + if (sanitise(beaconId).equals(stored)) { + wanted = true; + break; + } + } + + if (!wanted && !file.delete()) { + Log.d(TAG, "Could not delete stale derived addresses at " + file); + } + } + } + + private File fileFor(final String beaconId) { + return new File(this.directory, sanitise(beaconId) + SUFFIX); + } + + /** + * A beacon id reduced to something that can only name a file in this directory. + * + *

Beacon ids are UUIDs in practice, but this builds a path, and a value that arrived from + * an imported file has no business deciding which directory it lands in. + */ + private static String sanitise(final String beaconId) { + return beaconId.replaceAll("[^A-Za-z0-9_-]", "_"); + } + + private static String formatMac(final byte[] mac) { + return String.format(Locale.ROOT, "%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + } + + @Nullable + private static byte[] parseMac(final String address) { + final String[] parts = address.split(":"); + if (parts.length != 6) { + return null; + } + + final byte[] mac = new byte[6]; + try { + for (int i = 0; i < 6; i++) { + mac[i] = (byte) Integer.parseInt(parts[i], 16); + } + } catch (final NumberFormatException notAnAddress) { + return null; + } + return mac; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisement.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisement.java new file mode 100644 index 00000000..0f44c48b --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisement.java @@ -0,0 +1,104 @@ +package dev.wander.android.opentagviewer.ble; + +import androidx.annotation.Nullable; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * What an Apple Find My advertisement says about the accessory that sent it. + * + *

Pure parsing of the manufacturer payload, with no Android in it, so the rules below are + * covered by a JVM test rather than only by holding a tag and hoping. + * + *

Two payload shapes, and the difference is the whole point. An accessory separated + * from its owner broadcasts the full offline-finding beacon carrying its public key; one whose + * owner is present broadcasts a two-byte short form instead. The length byte is what tells them + * apart, which is also how AirGuard's {@code AppleFindMy.getConnectionState} reads it. + * + *

In practice only the separated form is useful here, and not because the short form is + * hard to parse. Measured on a real accessory: with its owner's phone in the room, the accessory + * did not appear in a scan at all, while every short-form advertisement seen came from something + * else nearby. That fits how the protocol works, since an accessory that holds a connection to + * its owner has no reason to advertise, and it is why a "not seen" result cannot be reported as + * "out of range" - see {@code NearbyTagSighting}. + */ +@AllArgsConstructor +@Getter +public final class FindMyAdvertisement { + + /** Apple's Bluetooth SIG company identifier. */ + public static final int APPLE_COMPANY_ID = 0x004C; + + /** Apple's "offline finding" advertisement type, the first payload byte. Package-visible + * so {@link NearbyTagWatcher} can hand it to the hardware scan filter - the filter and this + * parser must agree on what a Find My frame is, so there is one constant, not two. */ + static final byte TYPE_OFFLINE_FINDING = 0x12; + + /** Payload length of the full beacon an accessory sends once separated from its owner. */ + private static final byte LEN_SEPARATED = 0x19; + + /** Whether the sender is currently with its owner. */ + public enum State { + /** Separated from its owner: broadcasting the full beacon, and reachable over GATT. */ + SEPARATED, + /** Its owner is nearby. Recorded for completeness; see the class doc on why an + * accessory in this state is generally not seen at all. */ + OWNER_NEARBY, + } + + /** + * Battery level, from the top two bits of the status byte. + * + *

Same encoding FindMy.py reads (see its {@code BATTERY_LEVEL} map). Coarse by design: + * the protocol carries four levels, not a percentage. + */ + public enum BatteryLevel { + FULL, + MEDIUM, + LOW, + VERY_LOW, + } + + private final State state; + private final BatteryLevel batteryLevel; + + /** The raw status byte, kept so a bug report can quote it rather than only our reading. */ + private final int statusByte; + + /** + * Parses Apple manufacturer data, or returns null when it is not a Find My advertisement. + * + * @param appleManufacturerData the payload for {@link #APPLE_COMPANY_ID}, as returned by + * {@code ScanRecord.getManufacturerSpecificData}. Null-safe: + * most devices in any scan carry no Apple data at all. + */ + @Nullable + public static FindMyAdvertisement parse(@Nullable final byte[] appleManufacturerData) { + // Three bytes minimum: type, length, status. The short form is exactly this long, so + // anything below it cannot be read even to establish the state. + if (appleManufacturerData == null || appleManufacturerData.length < 3) { + return null; + } + if (appleManufacturerData[0] != TYPE_OFFLINE_FINDING) { + return null; + } + + final State state = appleManufacturerData[1] == LEN_SEPARATED + ? State.SEPARATED + : State.OWNER_NEARBY; + + final int status = appleManufacturerData[2] & 0xFF; + return new FindMyAdvertisement(state, batteryLevelOf(status), status); + } + + private static BatteryLevel batteryLevelOf(final int statusByte) { + switch ((statusByte >> 6) & 0b11) { + case 0b01: return BatteryLevel.MEDIUM; + case 0b10: return BatteryLevel.LOW; + case 0b11: return BatteryLevel.VERY_LOW; + case 0b00: + default: return BatteryLevel.FULL; + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyAccessoryScanner.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyAccessoryScanner.java new file mode 100644 index 00000000..eaab8958 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyAccessoryScanner.java @@ -0,0 +1,94 @@ +package dev.wander.android.opentagviewer.ble; + +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothManager; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanResult; +import android.bluetooth.le.ScanSettings; +import android.content.Context; + +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.core.Single; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Scans for a BLE advertisement whose address matches one of an accessory's currently-expected + * MAC addresses - see {@link BleAccessoryMatcher} - and resolves with the first one seen. + * + *

Unfiltered scan rather than a {@code ScanFilter} on the address, deliberately: the address + * that matters is the one Android reports on the {@link ScanResult}, and a filter is matched + * against the *raw advertisement bytes* the platform saw before it decided what address to + * report - the two need not agree on every OEM's stack. Matching after the fact in + * {@link BleAccessoryMatcher} is the same trade AirGuard and the TrackerHunter prototype this + * was ported from both made, for the same reason. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class NearbyAccessoryScanner { + + /** A scan finished without seeing any of the candidate addresses. */ + public static final class NotNearbyException extends Exception { + NotNearbyException() { + super("No candidate MAC address was seen advertising within the scan window"); + } + } + + @SuppressLint("MissingPermission") + public static Single findNearby( + final Context context, final Set candidateMacs, final long timeoutMs) { + return Observable.create(emitter -> { + final BluetoothManager manager = + (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE); + final BluetoothAdapter adapter = manager == null ? null : manager.getAdapter(); + final BluetoothLeScanner scanner = + adapter == null ? null : adapter.getBluetoothLeScanner(); + + if (scanner == null) { + emitter.onError(new IllegalStateException( + "No BLE scanner available (Bluetooth off, or unsupported)")); + return; + } + + final ScanCallback callback = new ScanCallback() { + @Override + public void onScanResult(final int callbackType, final ScanResult result) { + final BluetoothDevice device = result.getDevice(); + if (BleAccessoryMatcher.matches(device.getAddress(), candidateMacs) + && !emitter.isDisposed()) { + emitter.onNext(device); + emitter.onComplete(); + } + } + + @Override + public void onScanFailed(final int errorCode) { + if (!emitter.isDisposed()) { + emitter.onError(new IllegalStateException( + "BLE scan failed (errorCode=" + errorCode + ")")); + } + } + }; + + // LOW_LATENCY over the default balanced mode: this only ever runs for the few + // seconds after the user explicitly asked to trigger a sound, not continuously in + // the background, so there is no battery budget to protect here. + final ScanSettings settings = new ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build(); + scanner.startScan(null, settings, callback); + + emitter.setCancellable(() -> scanner.stopScan(callback)); + }) + .firstOrError() + .timeout(timeoutMs, TimeUnit.MILLISECONDS) + .onErrorResumeNext(error -> error instanceof java.util.concurrent.TimeoutException + ? Single.error(new NotNearbyException()) + : Single.error(error)); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java new file mode 100644 index 00000000..694ceb16 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java @@ -0,0 +1,261 @@ +package dev.wander.android.opentagviewer.ble; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import dev.wander.android.opentagviewer.python.AccessoryMacResolver; + +/** + * Which beacon a scanned BLE address belongs to, for every tag at once. + * + *

Why this exists rather than resolving per scan result. + * {@code AccessoryMacResolver.currentMacAddresses} starts a Python interpreter and runs an EC + * derivation. A scan in an ordinary flat produces tens of results per second, and a screen left + * open produces them for as long as it is open, so resolving per result would be one interpreter + * start per advertisement of anything, ours or not. Resolving once for every tag and matching + * against a map turns that into a hash lookup. + * + *

Why it expires. The addresses are rolling keys: an accessory moves to the next one + * roughly every 15 minutes, and a fetch that updates a tag's alignment changes which addresses + * are predicted at all. A map built once and kept would quietly stop matching, which presents as + * "the tag is never nearby" rather than as anything failing. + * + *

No Android and no Bluetooth in here, so the expiry rule and the matching are covered by a + * JVM test; the clock is a parameter for the same reason. + * + *

Written and read on different threads. {@link #rebuild} runs on an Rx io thread + * (it is blocking Python), while {@link #matchFor} runs on the Bluetooth stack's scan + * callback thread, once per advertisement of anything. Hence the volatile reference that is + * swapped whole rather than a map mutated in place: a reader sees either the old index or the + * new one, never a half-built or momentarily empty in-between - a race here would not crash, + * it would drop matches, which presents as "the tag is never nearby". + */ +public final class NearbyTagIndex { + private static final String TAG = NearbyTagIndex.class.getSimpleName(); + + + /** + * How long a built index is trusted. + * + *

Under the 15 minute rollover interval on purpose. Rebuilding slightly too often costs + * one Python call per tag; rebuilding too late costs sightings, and a missed sighting is + * indistinguishable from an absent tag. + */ + static final long MAX_AGE_MS = TimeUnit.MINUTES.toMillis(10); + + private volatile Map matchByMac = Map.of(); + + /** + * Which tag an address belongs to, and the index its key was derived at. + * + *

The index is carried, not acted on. Only Python can tell a primary key from a + * secondary one, and that is what decides whether an index may be trusted - see + * {@code AccessoryMacResolver#recordSeen}. Passing it on as a hint lets the correction check + * one index instead of re-deriving a 48-hour window, which is the difference between three + * key derivations and around 1150. + */ + public static final class Match { + private final String beaconId; + /** + * Where this address came from, or null when that is not known. + * + *

Null for an address read back from {@link DerivedAddressStore}: the index a + * secondary key is reported at depends on where the deriving range began, so it is an + * artefact of how the work was split rather than a fact worth keeping. It travels on as + * a hint, and a missing hint simply costs Python one wide check. + */ + @Nullable + private final Integer keyIndex; + + Match(final String beaconId, @Nullable final Integer keyIndex) { + this.beaconId = beaconId; + this.keyIndex = keyIndex; + } + + public String getBeaconId() { + return this.beaconId; + } + + @Nullable + public Integer getKeyIndex() { + return this.keyIndex; + } + } + private volatile long builtAtMs = Long.MIN_VALUE; + + /** True when this has never been built, or was built long enough ago to be doubted. */ + public boolean isStale(final long nowMs) { + return this.builtAtMs == Long.MIN_VALUE || nowMs - this.builtAtMs >= MAX_AGE_MS; + } + + /** + * Resolves every tag's current candidate addresses and replaces the index with them. + * + *

Blocking, once per tag. Call it off the main thread. + * + * @param accessoryJsonByBeaconId the persisted accessory JSON per beacon. An entry the + * resolver cannot answer for - unreadable JSON, or a + * candidate window too wide to be worth deriving - is + * skipped rather than failing the rebuild: such a tag should + * cost only its own sightings, not everyone else's. + */ + public void rebuild( + final Map accessoryJsonByBeaconId, + final AccessoryMacResolver resolver, + final long nowMs) { + this.rebuild(accessoryJsonByBeaconId, resolver, nowMs, null); + } + + /** + * As {@link #rebuild(Map, AccessoryMacResolver, long)}, keeping what it derives in {@code + * store} and deriving only what is missing from it. + * + * @param store where derived addresses are kept across launches, or null to derive + * everything every time, which is what a test without a filesystem wants. + */ + public void rebuild( + final Map accessoryJsonByBeaconId, + final AccessoryMacResolver resolver, + final long nowMs, + @Nullable final DerivedAddressStore store) { + final Map rebuilt = new HashMap<>(); + + for (final Map.Entry entry : accessoryJsonByBeaconId.entrySet()) { + // Only the address is wanted here; the key index each maps to is not this class's + // business - see AccessoryMacResolver#recordSeen on why only Python may act on it. + final Map candidates = + store == null + ? resolver.currentMacAddresses(entry.getValue()) + : addressesFor(entry.getKey(), entry.getValue(), resolver, store); + + // **Null is a documented answer, not a broken one, and it must not stop the loop.** + // The interface permits it for an accessory the resolver cannot read, and for one + // whose candidate window is too wide to derive - which is what an owner's own Apple + // device looks like, since a phone has no rolling-key alignment. + // + // Latent rather than observed: the Chaquopy implementation maps Python's None to an + // empty map, so no build has actually thrown here. A different implementation, or a + // test double, may return null as the signature allows - and then one entry would + // cost every other entry its sightings, which is what the parameter note forbids. + if (candidates == null) { + Log.d(TAG, "No candidate addresses for beaconId=" + entry.getKey() + + "; leaving it out of the index rather than dropping the rest"); + continue; + } + + for (final Map.Entry candidate : candidates.entrySet()) { + // A null value is an address whose index is not known, which is ordinary for one + // recovered from the store. Only a null address is useless. + if (candidate.getKey() != null) { + // Upper-cased on the way in so lookups need no normalisation per scan + // result, which is the hot path. Android reports uppercase and FindMy.py + // produces uppercase, but neither promises it forever. + rebuilt.put(candidate.getKey().toUpperCase(Locale.ROOT), + new Match(entry.getKey(), candidate.getValue())); + } + } + } + + // Swapped whole, not mutated in place - see the class doc on the reader thread. + this.matchByMac = rebuilt; + this.builtAtMs = nowMs; + } + + /** + * How wide a stored range is allowed to grow before it is started over. + * + *

The window creeps upward with the clock, about a hundred indices a day, so the union of + * everything ever derived grows without limit for a tag that is kept for years. At this width + * it is roughly a year of history and a few megabytes; past it, the oldest part is certainly + * dead and is not worth carrying. Starting over costs one derivation of the current window. + */ + static final int MAX_STORED_INDICES = 40_000; + + /** + * The addresses for one tag, derived only where the stored copy does not already have them. + * + *

Extended rather than replaced, and a gap in between is derived rather than skipped. + * The stored range and the wanted window normally overlap, since the window moves by one + * index every fifteen minutes. When they do not, the app has simply not been opened for a + * few days, and deriving from the top of what is held up to the top of the window covers the + * gap and the window together - so the result is contiguous and nothing is recorded as + * covered that was never derived. Requiring them to touch, and starting over when they did + * not, threw away a range that had been widened over hours because somebody left the app + * closed for four days, which is exactly the case the widening exists for. + */ + private static Map addressesFor( + final String beaconId, + final String accessoryJson, + final AccessoryMacResolver resolver, + final DerivedAddressStore store) { + + final AccessoryMacResolver.IndexRange window = resolver.candidateWindow(accessoryJson); + if (window == null || window.width() == 0) { + // Unreadable, or an accessory with no rolling keys at all. Ask the way that has + // always answered for those, and keep nothing. + return resolver.currentMacAddresses(accessoryJson); + } + + final DerivedAddressStore.Derived held = store.load(beaconId); + + if (held != null && held.covers(window.getLo(), window.getHi())) { + // The whole point: nothing is derived at all, on the launch where deriving is most + // expensive because everything else is starting up at the same time. + Log.d(TAG, "Reusing " + held.getAddresses().size() + " stored address(es) for" + + " beaconId=" + beaconId + " covering " + held.getLo() + ".." + held.getHi()); + return held.getAddresses(); + } + + // Only the total width can rule out extending: everything else is a gap, and a gap is + // derived along with the window rather than being a reason to discard what is held. + final boolean extendable = held != null + && Math.max(held.getHi(), window.getHi()) + - Math.min(held.getLo(), window.getLo()) < MAX_STORED_INDICES; + + final Map addresses = + extendable ? new HashMap<>(held.getAddresses()) : new HashMap<>(); + + final int haveLo = extendable ? held.getLo() : Integer.MAX_VALUE; + final int haveHi = extendable ? held.getHi() : Integer.MIN_VALUE; + + if (!extendable) { + addresses.putAll(resolver.addressesBetween( + accessoryJson, window.getLo(), window.getHi())); + } else { + if (window.getLo() < haveLo) { + addresses.putAll(resolver.addressesBetween( + accessoryJson, window.getLo(), haveLo - 1)); + } + if (window.getHi() > haveHi) { + addresses.putAll(resolver.addressesBetween( + accessoryJson, haveHi + 1, window.getHi())); + } + } + + final int storedLo = extendable ? Math.min(haveLo, window.getLo()) : window.getLo(); + final int storedHi = extendable ? Math.max(haveHi, window.getHi()) : window.getHi(); + + store.save(beaconId, storedLo, storedHi, addresses); + return addresses; + } + + /** The tag this address belongs to and the index it came from, or null if it is not ours. */ + @Nullable + public Match matchFor(@Nullable final String scannedAddress) { + if (scannedAddress == null) { + return null; + } + return this.matchByMac.get(scannedAddress.toUpperCase(Locale.ROOT)); + } + + /** How many addresses are currently being watched for, across all tags. For logging. */ + public int size() { + return this.matchByMac.size(); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java new file mode 100644 index 00000000..96d79253 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java @@ -0,0 +1,96 @@ +package dev.wander.android.opentagviewer.ble; + +import androidx.annotation.StringRes; + +import dev.wander.android.opentagviewer.R; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Which string resources describe a sighting, decided without touching a {@code Context}. + * + *

Split out so the choice is covered by a JVM test. Formatting it needs resources and a + * locale, but choosing which resource does not, and the choice is the part with rules in + * it. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class NearbyTagLabel { + + /** + * The short battery word for a tag card, e.g. "low". + * + *

Deliberately not the {@code battery_level_*} strings the debug panel uses. Those spell + * out a percentage range and a caveat, which is right for a diagnostics row and far too long + * for a line that also has to carry "Nearby" on a card sized to a phone. + */ + @StringRes + public static int shortBatteryLabel(final FindMyAdvertisement.BatteryLevel level) { + switch (level) { + case MEDIUM: return R.string.battery_short_medium; + case LOW: return R.string.battery_short_low; + case VERY_LOW: return R.string.battery_short_very_low; + case FULL: + default: return R.string.battery_short_full; + } + } + + /** Filled dot, for {@link #signalStrengthBars}. */ + private static final char BAR_FILLED = '●'; + + /** Hollow dot, for {@link #signalStrengthBars}. */ + private static final char BAR_EMPTY = '○'; + + /** How many dots {@link #signalStrengthBars} draws - filled and hollow together. */ + private static final int SIGNAL_BAR_COUNT = 5; + + /** + * A five-dot signal meter for a sighting's RSSI, e.g. {@code "●●●○○"} - no words, so no + * string resource and no locale to get it from. + * + *

Deliberately not a distance. An earlier version of this feature converted RSSI + * to metres through the standard log-distance path loss model, calibrated against a real + * accessory at a measured 50 cm. Moved to 2 m, the same accessory read RSSI values that + * overlapped the readings taken at 50 cm - the noise from multipath and antenna orientation + * on a desk was larger than the signal difference between those two distances, so no + * calibration constant could have told them apart. A number would have kept implying a + * precision the underlying signal does not have. A dot count makes a weaker claim that is + * actually true: the reading went up or down, which is still useful for homing in on a tag + * while moving, without pretending to say by how far. + * + *

{@link #signalStrengthLevel}'s thresholds are not calibrated to a particular distance + * for that reason - they only need to separate stronger readings from weaker ones as someone + * moves. + */ + public static String signalStrengthBars(final int rssi) { + final int filled = signalStrengthLevel(rssi); + final StringBuilder bars = new StringBuilder(SIGNAL_BAR_COUNT); + for (int i = 0; i < SIGNAL_BAR_COUNT; i++) { + bars.append(i < filled ? BAR_FILLED : BAR_EMPTY); + } + return bars.toString(); + } + + /** + * How many of {@link #signalStrengthBars}' five dots are filled, from 1 (weakest) to 5 + * (strongest) - never 0, since a sighting existing at all means some signal was heard. + * + *

10 dB per step, which is also the noise band the field test behind + * {@link #signalStrengthBars}'s doc turned up: two readings of the same real accessory, + * standing still, varied by that much on their own. + */ + static int signalStrengthLevel(final int rssi) { + if (rssi >= -55) { + return 5; + } + if (rssi >= -65) { + return 4; + } + if (rssi >= -75) { + return 3; + } + if (rssi >= -85) { + return 2; + } + return 1; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSighting.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSighting.java new file mode 100644 index 00000000..7d8997ba --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSighting.java @@ -0,0 +1,63 @@ +package dev.wander.android.opentagviewer.ble; + +import androidx.annotation.Nullable; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * One of the user's own tags, seen by this phone's radio just now. + * + *

A sighting is a positive claim only, and the absence of one claims nothing. Seeing a + * tag proves it was in range at that instant and reports what its own beacon said about its + * battery. Not seeing it means any of: out of range, with its owner and therefore not + * advertising at all (measured, see {@link FindMyAdvertisement}), or simply silent during the + * window. Those are indistinguishable from here, so nothing may present "no sighting" as "out of + * range" - the honest rendering is to show what was seen and stay quiet otherwise. + * + *

The battery level is the reason this is worth surfacing at all. The value the app has + * otherwise comes from the iCloud record, which only Apple devices ever refresh, so for a tag + * imported from a file it is whatever was true when the export was made - possibly years ago, + * which is why it sits behind the debug switch. This one comes from the tag itself, in the + * moment it was heard. + */ +@AllArgsConstructor +@Getter +public final class NearbyTagSighting { + + private final String beaconId; + + /** + * The key index the matched address was derived at, as a hint for the alignment correction. + * + *

Carried, never acted on here. Only Python can tell whether that index came from + * a primary or a secondary key, and that is what decides whether it may be trusted - see + * {@code AccessoryMacResolver#recordSeen}. Passing it along lets the correction verify one + * index instead of re-deriving a 48-hour window: three key derivations instead of about + * 1150. + */ + @Nullable + private final Integer keyIndex; + + /** Signal strength in dBm. Negative; closer to zero is nearer. */ + private final int rssi; + + private final FindMyAdvertisement.BatteryLevel batteryLevel; + + /** + * The status byte {@link #batteryLevel} was decoded from. + * + *

Carried alongside the reading rather than discarded once it has been decoded, because + * the reading is two bits of it interpreted against a table only partly confirmed outside + * Apple - see {@link dev.wander.android.opentagviewer.util.parse.LocationReportFields}. It + * is what gets persisted with a stored reading, so a disputed one can be re-derived from + * what was actually received. + */ + private final int statusByte; + + /** Whether the beacon said it was separated from its owner. See {@link FindMyAdvertisement}. */ + private final FindMyAdvertisement.State state; + + /** Wall-clock time of the sighting, so a stale one can be aged out rather than left on screen. */ + private final long seenAtMs; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSightings.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSightings.java new file mode 100644 index 00000000..04232a53 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSightings.java @@ -0,0 +1,60 @@ +package dev.wander.android.opentagviewer.ble; + +import androidx.annotation.Nullable; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * The most recent sighting of each tag, and whether it is recent enough to still show. + * + *

The ageing is the point. A sighting is a claim about a moment, so one left on screen + * becomes a lie as soon as the tag is carried out of range: the badge would still read "nearby" + * for a tag that is gone. Nothing tells us it left - we simply stop hearing it - so the only + * honest rendering is to let the claim expire on its own. + * + *

No Android in here, and the clock is a parameter, so the expiry rule is covered by a JVM + * test rather than by watching a screen and waiting. + */ +public final class NearbyTagSightings { + + /** + * How long a sighting is worth showing. + * + *

A separated accessory advertises every second or two, but even + * {@code SCAN_MODE_BALANCED} - see {@code NearbyTagWatcher} - still duty-cycles rather than + * listening continuously, so gaps of a few seconds between sightings are normal for a tag + * sitting right next to the phone. This is generous enough to ride those out and short + * enough that a tag carried away stops claiming to be here within about half a minute. + * + *

Public because it is the one answer to "how long may a sighting be presented as + * current", wherever that presentation happens - the device info screen's live battery row + * ages out on the same clock rather than inventing a second one. + */ + public static final long FRESH_FOR_MS = TimeUnit.SECONDS.toMillis(30); + + private final Map latestByBeaconId = new ConcurrentHashMap<>(); + + /** Written from the scan callback, read on the main thread, hence the concurrent map. */ + public void record(final NearbyTagSighting sighting) { + this.latestByBeaconId.put(sighting.getBeaconId(), sighting); + } + + /** + * The last sighting of this tag, or null if there is none or it is too old to stand behind. + */ + @Nullable + public NearbyTagSighting freshFor(final String beaconId, final long nowMs) { + final NearbyTagSighting sighting = this.latestByBeaconId.get(beaconId); + if (sighting == null || nowMs - sighting.getSeenAtMs() >= FRESH_FOR_MS) { + return null; + } + return sighting; + } + + /** Drops everything, for when scanning stops and nothing may keep claiming to be current. */ + public void clear() { + this.latestByBeaconId.clear(); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java new file mode 100644 index 00000000..b6440ccc --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java @@ -0,0 +1,470 @@ +package dev.wander.android.opentagviewer.ble; + +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothManager; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanFilter; +import android.bluetooth.le.ScanRecord; +import android.bluetooth.le.ScanResult; +import android.bluetooth.le.ScanSettings; +import android.content.Context; +import android.util.Log; + +import androidx.annotation.Nullable; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import dev.wander.android.opentagviewer.python.AccessoryMacResolver; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * Reports the user's own tags as this phone hears them, for as long as somebody is subscribed. + * + *

Who runs it decides what it costs. A screen subscribes in {@code onResume} and + * disposes in {@code onPause}, so the radio is on only while somebody is looking - that is the + * default, and it keeps the app a display feature. {@code NearbyScanService} runs the same class + * continuously when the user turns background scanning on, which is a recording feature and is + * why it is opt-in and carries a permanent notification. + * + *

The difference reaches this class as {@link #scanMode}, and the two callers sit at + * opposite ends of it. A screen is open because somebody is looking for a tag right now, so it + * scans at {@code SCAN_MODE_LOW_LATENCY} - the radio listening continuously, which is what makes + * the signal meter move as you walk toward something. That is affordable precisely because a + * screen is open for minutes, not days. + * + *

The service takes {@code SCAN_MODE_BALANCED} instead: a quarter of the radio time, running + * all day. Cheaper still exists, and was tried - low power left gaps of over a minute with a tag + * in a pocket, which the left-behind rule then has to see through, and every gap it cannot costs + * a full-power verification burst of its own. + * + *

{@code SCAN_MODE_BALANCED} - a middle ground between the low-latency mode + * {@link NearbyAccessoryScanner} uses and this class's own original {@code SCAN_MODE_LOW_POWER}. + * Low-power's short scan window and multi-second sleep between them meant several of a tag's + * own advertisements arrived in a burst whenever a window happened to line up, then nothing for + * several seconds until the next one - honest about what low-power scanning actually looks + * like, but a person watching this screen is specifically looking for a tag right now, the same + * reason {@link NearbyAccessoryScanner} justifies its own higher power draw. Still not + * low-latency: this runs for as long as a screen stays open rather than for a few bounded + * seconds after a tap, so it keeps some of the duty cycle low-latency forgoes entirely. + */ +public class NearbyTagWatcher { + private static final String TAG = NearbyTagWatcher.class.getSimpleName(); + + /** Injectable so a test can drive the whole pipeline without a radio. */ + interface Clock { + long nowMs(); + } + + /** + * Told, off the scan callback thread and throttled, when a sighting matches one of the + * caller's own tags - so a passive scan can feed alignment self-correction the same way the + * ring button's explicit scan does. Real: {@code BeaconRepository#recordAccessorySighting}. + * + *

Without this, a tag only ever heard through this class - never rung, and refreshed by + * the periodic Apple-network fetch only as often as that runs - has no way to correct a + * stored alignment that has drifted since the last fetch. It stays inside + * {@code currentMacAddresses}' 12 hour margin for a while and then, once the drift exceeds + * that, simply stops being found - with nothing failing anywhere to say why. + * + *

Handed the whole sighting, not just the address it was heard at. Alignment only + * needs the address, but the same advertisement also carries the tag's battery level, and + * that is worth keeping past the moment it was heard - see + * {@code BeaconRepository#storeLastSighting}. Both writes belong to the same event and are + * throttled by the same rule, so there is one callback carrying everything the advertisement + * said rather than a second listener firing on its own schedule. + */ + public interface SightingListener { + void onSighting(NearbyTagSighting sighting, String mac); + } + + /** + * How often {@link SightingListener#onSighting} fires for the same beacon. + * + *

A tag in range advertises every one to three seconds, and each one is a candidate + * correction - reporting every single one would start a Python interpreter that often. A + * correction that already matches the stored alignment is a no-op on the far side anyway, + * so nothing is lost by not attempting most of them. + */ + static final long SIGHTING_LISTENER_INTERVAL_MS = TimeUnit.MINUTES.toMillis(1); + + private final AccessoryMacResolver macResolver; + private final NearbyTagIndex index; + private final Clock clock; + + @Nullable + private final SightingListener sightingListener; + + /** Written and read on the Bluetooth scan callback thread, but also constructed and first + * touched elsewhere - concurrent map so there is no thread this is unsafe from. */ + private final Map lastListenerCallMs = new ConcurrentHashMap<>(); + + /** Guards {@link #maybeRebuildIndex} so a stale index triggers one rebuild, not one per + * advertisement that arrives while the first is still running. */ + private final AtomicBoolean indexRebuildInFlight = new AtomicBoolean(false); + + /** + * Where derived addresses are kept between launches, once a scan has supplied a context. + * + *

Set in {@link #watch} rather than injected, because it needs the app's files directory + * and this class is constructed by screens and a service that have no reason to know about + * one. Null until then, which is what the JVM tests run against: they exercise the matching, + * and a test that has no filesystem should derive rather than persist. + */ + @Nullable + private volatile DerivedAddressStore derivedAddresses; + + /** + * Looks further back for tags that are not turning up. Null until {@link #watch} supplies a + * context, for the same reason as {@link #derivedAddresses}. + */ + @Nullable + private volatile WideningSearch wideningSearch; + + /** + * When each of our tags was last heard, which is what decides who is worth widening for. + * + *

Written on the scan callback thread and read on an Rx io thread, hence the concurrent + * map. Not persisted: after a restart every tag reads as never heard, which widens for all + * of them until they turn up - the right way round, since a restart is also when the index + * knows least. + */ + private final Map lastHeardMs = new ConcurrentHashMap<>(); + + /** + * How hard the radio listens. + * + *

{@code SCAN_MODE_BALANCED} for a screen, {@code SCAN_MODE_LOW_POWER} for the service. + * The difference is the duty cycle: low power leaves longer gaps between listening windows, + * so a tag takes longer to be noticed - acceptable when nobody is watching the screen, and + * not acceptable when they are. + */ + private final int scanMode; + + public NearbyTagWatcher(final AccessoryMacResolver macResolver) { + this(macResolver, null); + } + + public NearbyTagWatcher( + final AccessoryMacResolver macResolver, @Nullable final SightingListener listener) { + this(macResolver, listener, ScanSettings.SCAN_MODE_BALANCED); + } + + public NearbyTagWatcher( + final AccessoryMacResolver macResolver, + @Nullable final SightingListener listener, + final int scanMode) { + this(macResolver, listener, scanMode, new NearbyTagIndex(), System::currentTimeMillis); + } + + NearbyTagWatcher(final AccessoryMacResolver macResolver, + @Nullable final SightingListener sightingListener, + final int scanMode, + final NearbyTagIndex index, + final Clock clock) { + this.macResolver = macResolver; + this.sightingListener = sightingListener; + this.scanMode = scanMode; + this.index = index; + this.clock = clock; + } + + /** + * Emits a {@link NearbyTagSighting} every time one of the given tags is heard. + * + *

Emits repeatedly for the same tag, once per advertisement, rather than once per tag: + * the caller wants a live signal strength and a fresh timestamp, not a one-off announcement. + * + *

Never errors on an ordinary failure. Missing permission or a Bluetooth adapter that is + * off simply produce no sightings, because there is nothing for a caller to do about either + * beyond what it already does for the ring button, and a screen must not break because the + * radio is off. + * + * @param accessoryJsonByBeaconId the persisted accessory JSON per beacon, for the tags worth + * watching for. + */ + @SuppressLint("MissingPermission") + public Observable watch( + final Context context, final Map accessoryJsonByBeaconId) { + return Observable.create(emitter -> { + if (!BlePermissions.granted(context)) { + Log.d(TAG, "Not watching for nearby tags: BLE permission not granted"); + emitter.onComplete(); + return; + } + if (accessoryJsonByBeaconId.isEmpty()) { + emitter.onComplete(); + return; + } + + // Blocking, one interpreter start per tag - hence subscribeOn(io) below, and hence + // the index rather than resolving per scan result. See NearbyTagIndex. + if (this.derivedAddresses == null) { + this.derivedAddresses = + new DerivedAddressStore(context.getApplicationContext().getFilesDir()); + } + this.derivedAddresses.forgetAllExcept(accessoryJsonByBeaconId.keySet()); + + if (this.wideningSearch == null) { + this.wideningSearch = new WideningSearch(this.macResolver, this.derivedAddresses); + } + this.wideningSearch.started(this.clock.nowMs()); + + if (this.index.isStale(this.clock.nowMs())) { + this.index.rebuild(accessoryJsonByBeaconId, this.macResolver, this.clock.nowMs(), + this.derivedAddresses); + Log.d(TAG, "Watching " + this.index.size() + " candidate address(es) for " + + accessoryJsonByBeaconId.size() + " tag(s)"); + } + + final BluetoothManager manager = + (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE); + final BluetoothAdapter adapter = manager == null ? null : manager.getAdapter(); + final BluetoothLeScanner scanner = + adapter == null ? null : adapter.getBluetoothLeScanner(); + if (scanner == null) { + Log.d(TAG, "Not watching for nearby tags: Bluetooth is off or unsupported"); + emitter.onComplete(); + return; + } + + final ScanCallback callback = new ScanCallback() { + @Override + public void onScanResult(final int callbackType, final ScanResult result) { + // Checked per scan result, of anything, not only our own tags: once the + // index is stale, our own tag's advertisements are exactly the ones that + // no longer match, so they cannot be the trigger. + maybeRebuildIndex(accessoryJsonByBeaconId); + maybeWidenSearch(accessoryJsonByBeaconId); + + final NearbyTagSighting sighting = sightingFrom(result); + if (sighting == null) { + return; + } + if (!emitter.isDisposed()) { + emitter.onNext(sighting); + } + maybeNotifySightingListener(sighting, result.getDevice().getAddress()); + } + + @Override + public void onScanFailed(final int errorCode) { + // Not an error onto the subscriber: see the method contract. A screen that + // cannot scan shows no badges, which is the same as seeing nothing. + Log.w(TAG, "Nearby tag scan failed (errorCode=" + errorCode + ")"); + if (!emitter.isDisposed()) { + emitter.onComplete(); + } + } + }; + + // Filtered in hardware, not only in software. An unfiltered scan delivered every + // BLE frame of every device in earshot to the callback - tens per second in an + // ordinary flat, nearly all of them discarded by sightingFrom. The controller can + // do that discarding itself: Apple's company ID plus the offline-finding type byte + // is exactly the check FindMyAdvertisement.parse starts with, so nothing that would + // have matched is lost, and the callback now fires only for Find My frames. + final List findMyFramesOnly = List.of(new ScanFilter.Builder() + .setManufacturerData(FindMyAdvertisement.APPLE_COMPANY_ID, + new byte[]{FindMyAdvertisement.TYPE_OFFLINE_FINDING}, + new byte[]{(byte) 0xFF}) + .build()); + final ScanSettings settings = new ScanSettings.Builder() + .setScanMode(this.scanMode) + .build(); + + scanner.startScan(findMyFramesOnly, settings, callback); + + // Restarted well before the platform's 30 minute mark: Android silently downgrades + // any scan running longer than that to SCAN_MODE_OPPORTUNISTIC, which only delivers + // results while some other app happens to be scanning - a screen left open for half + // an hour would go quietly deaf, the same presentation as every other failure this + // class has had to chase. One stop/start pair per 20 minutes is far inside the + // 5-starts-per-30-seconds budget. + final Disposable scanRefresh = Observable + .interval(SCAN_RESTART_INTERVAL_MS, SCAN_RESTART_INTERVAL_MS, + TimeUnit.MILLISECONDS, Schedulers.io()) + .subscribe(tick -> { + try { + scanner.stopScan(callback); + scanner.startScan(findMyFramesOnly, settings, callback); + Log.d(TAG, "Restarted the nearby scan before the platform's " + + "long-scan downgrade"); + } catch (final Exception e) { + // Bluetooth went away between the stop and the start. Complete, so + // the caller's ordinary retry takes over rather than this looking + // like a scan that is still running. + Log.w(TAG, "Could not restart the nearby scan", e); + if (!emitter.isDisposed()) { + emitter.onComplete(); + } + } + }); + + emitter.setCancellable(() -> { + Log.d(TAG, "Stopped watching for nearby tags"); + scanRefresh.dispose(); + + // **Stopping a scan the adapter has already ended throws, and on this path a + // throw is fatal.** stopScan raises IllegalStateException("BT Adapter is not + // turned ON") when Bluetooth went off while we were watching - which is an + // ordinary thing for somebody to do - and a cancellable that throws during + // disposal has no subscriber left to receive it, so RxJava hands it to the + // global error handler and the process goes down. Not a crash on some exotic + // path either: turn Bluetooth off with the map open, then leave the screen. + // + // Nothing is lost by swallowing it. The adapter turning off is what stops a + // scan; there is no scan left to stop. Same reasoning as the restart above, + // which already catches this for the same reason. + try { + scanner.stopScan(callback); + } catch (final Exception bluetoothWentAway) { + Log.d(TAG, "The nearby scan had already ended with the adapter", + bluetoothWentAway); + } + }); + }).subscribeOn(Schedulers.io()); + } + + /** + * How often the running scan is stopped and started again - under Android's 30 minute + * limit, past which a continuous scan is silently downgraded to opportunistic delivery. + */ + static final long SCAN_RESTART_INTERVAL_MS = TimeUnit.MINUTES.toMillis(20); + + /** + * Rebuilds the index in the background once it has gone stale, mid-subscription. + * + *

Without this, a watch outliving the key rollover goes quietly deaf. The index + * is checked and rebuilt when {@link #watch} subscribes, but a screen left open longer than + * {@link NearbyTagIndex#MAX_AGE_MS} used to keep matching against rolled-past addresses for + * as long as the subscription lived - the tag next to the phone simply stopped appearing, + * with nothing failing anywhere, until an onPause/onResume bounce built a fresh watcher. + * Exactly the failure mode the expiry rule exists to prevent, made unreachable by only + * consulting it once. + * + *

Cheap on the hot path: a stale check is two long compares, and the rebuild itself - + * blocking Python, one interpreter call per tag - is handed to {@link Schedulers#io()} + * behind a single-flight guard. Until it completes, matching continues against the old + * index, which can only miss what it would have missed anyway. + */ + private void maybeRebuildIndex(final Map accessoryJsonByBeaconId) { + if (!this.index.isStale(this.clock.nowMs())) { + return; + } + if (!this.indexRebuildInFlight.compareAndSet(false, true)) { + return; + } + Schedulers.io().scheduleDirect(() -> { + try { + this.index.rebuild(accessoryJsonByBeaconId, this.macResolver, this.clock.nowMs(), + this.derivedAddresses); + Log.d(TAG, "Rebuilt the nearby index mid-watch: " + this.index.size() + + " candidate address(es) for " + accessoryJsonByBeaconId.size() + + " tag(s)"); + } finally { + this.indexRebuildInFlight.set(false); + } + }); + } + + /** + * Looks one chunk further back for a tag nobody has heard, when a round is due. + * + *

Driven by arriving advertisements for the same reason {@link #maybeRebuildIndex} is: + * it is the one signal this class reliably gets, and it costs nothing on the scan thread + * because everything expensive is handed to {@link Schedulers#io()} behind the same + * single-flight guard. Most of those advertisements belong to strangers, which is fine - + * they are a clock, not evidence. + * + *

The index is rebuilt straight after a round that derived something, because addresses + * that are only in the store and not in the index match nothing. + */ + private void maybeWidenSearch(final Map accessoryJsonByBeaconId) { + final WideningSearch search = this.wideningSearch; + if (search == null || !search.isDue(this.clock.nowMs())) { + return; + } + if (!this.indexRebuildInFlight.compareAndSet(false, true)) { + return; + } + + Schedulers.io().scheduleDirect(() -> { + try { + final String widened = search.widenOne( + accessoryJsonByBeaconId, this.lastHeardMs, this.clock.nowMs()); + + if (widened != null) { + this.index.rebuild(accessoryJsonByBeaconId, this.macResolver, + this.clock.nowMs(), this.derivedAddresses); + Log.d(TAG, "Index now holds " + this.index.size() + + " candidate address(es) after widening for beaconId=" + widened); + } + } finally { + this.indexRebuildInFlight.set(false); + } + }); + } + + /** + * One scan result turned into a sighting, or null if it is not one of ours. + * + *

Package-private and separated from the scan callback so the decision - is this Find My + * at all, is it a tag we own, what did it say - is reachable by a test without a radio. + */ + @Nullable + NearbyTagSighting sightingFrom(final ScanResult result) { + final ScanRecord record = result.getScanRecord(); + if (record == null) { + return null; + } + + final FindMyAdvertisement advertisement = FindMyAdvertisement.parse( + record.getManufacturerSpecificData(FindMyAdvertisement.APPLE_COMPANY_ID)); + if (advertisement == null) { + return null; + } + + // Most Find My advertisements in any scan belong to strangers; only ours resolve. + final NearbyTagIndex.Match match = this.index.matchFor(result.getDevice().getAddress()); + if (match == null) { + return null; + } + + // Noted here rather than in the emitter, so it is recorded even for a subscriber that + // has gone away: who is worth widening for is a fact about the radio, not about who + // happens to be listening. + this.lastHeardMs.put(match.getBeaconId(), this.clock.nowMs()); + + return new NearbyTagSighting(match.getBeaconId(), match.getKeyIndex(), result.getRssi(), + advertisement.getBatteryLevel(), advertisement.getStatusByte(), + advertisement.getState(), this.clock.nowMs()); + } + + /** + * Calls {@link #sightingListener}, throttled per beacon, off the calling thread. + * + *

Off-thread because the real listener persists to Room through a Python call - see the + * interface doc - and this runs from {@code onScanResult}, which must not block. + */ + void maybeNotifySightingListener(final NearbyTagSighting sighting, final String mac) { + if (this.sightingListener == null) { + return; + } + final long nowMs = this.clock.nowMs(); + final Long lastCallMs = this.lastListenerCallMs.get(sighting.getBeaconId()); + if (lastCallMs != null && nowMs - lastCallMs < SIGHTING_LISTENER_INTERVAL_MS) { + return; + } + this.lastListenerCallMs.put(sighting.getBeaconId(), nowMs); + + Schedulers.io().scheduleDirect(() -> this.sightingListener.onSighting(sighting, mac)); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/WideningSearch.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/WideningSearch.java new file mode 100644 index 00000000..66fd9ded --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/WideningSearch.java @@ -0,0 +1,210 @@ +package dev.wander.android.opentagviewer.ble; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import dev.wander.android.opentagviewer.python.AccessoryMacResolver; + +/** + * Looks further back for a tag nobody has heard, a little at a time, without being asked. + * + *

The problem it exists for. The addresses worth scanning for are worked out from the + * tag's stored key alignment, extrapolated forward at one index every fifteen minutes. That is + * right for a tag that has been running, which is the ordinary case even after months out of + * contact, because the index follows the tag's own clock and not the network. It is wrong for a + * tag whose true index has drifted away from the extrapolation: one that spent time without + * power, or whose stored alignment was pushed too high. Such a tag is then searched for at + * addresses it will never use, and from the outside is indistinguishable from a tag that is + * simply gone. That is the failure this closes. + * + *

Why widening rather than a button. A manual "search harder" action would put the + * question to the person least able to answer it: they cannot tell a tag that is missing from + * one that is out of step, which is exactly what they came to the app to find out. So the search + * widens on its own, and the only thing the user ever sees is that the tag turns up. + * + *

Why a little at a time. Deriving costs about two to three seconds per thousand + * indices on an idle phone, and was measured at sixty to a hundred while the app was starting up + * and competing with itself. Covering a hundred days in one go is therefore either half a minute + * or several, depending entirely on when it is attempted. In chunks it is a fixed, small cost + * that can be spent when there is room for it, and the whole range is covered within the hour + * either way. + * + *

Progress needs no state of its own. {@link DerivedAddressStore} records the range it + * holds, so the bottom of that range is exactly how far the search has got. Restarting the app, + * or the service being killed, costs nothing and resumes where it left off. + * + *

No Android in here, so the rule is covered by a JVM test. + */ +public final class WideningSearch { + private static final String TAG = WideningSearch.class.getSimpleName(); + + /** + * How many indices one round derives. + * + *

Small enough that a single round is affordable even on a device where derivation is + * running an order of magnitude slower than measured: at the worst rate seen, a hundred + * seconds per thousand indices, this is still under a minute of work that nothing is + * waiting on. + */ + static final int CHUNK_INDICES = 500; + + /** + * How far back the search is willing to go, as indices below the top of the current window. + * + *

A hundred days at four indices an hour. Past that the balance tips: the derivation is + * still cheap in chunks, but a tag that has been out of step for longer than that is more + * likely gone than out of step, and the addresses are worth less than the space they take. + */ + static final int TARGET_INDICES = 9_600; + + /** + * How recently a tag must have been heard to be left alone. + * + *

Deliberately long. Widening a tag that is merely quiet for a minute would spend the + * derivation on the tags least in need of it, and a tag in the same room is heard many times + * inside this window. + */ + static final long HEARD_RECENTLY_MS = TimeUnit.MINUTES.toMillis(10); + + /** + * The least time between rounds. + * + *

The point is that this never competes with anything. A round a minute covers a hundred + * days in about twenty minutes, which is far quicker than the situation it is for. + */ + static final long BETWEEN_ROUNDS_MS = TimeUnit.MINUTES.toMillis(1); + + /** + * How long after the watch starts before the first round. + * + *

The measured worst case for derivation was during app startup - sixty to a hundred + * seconds per thousand indices, against two to three when idle - so the one rule this must + * follow is to stay out of that window. Everything else it does is cheap; doing it at the + * wrong moment is not. + */ + static final long WARM_UP_MS = TimeUnit.MINUTES.toMillis(2); + + private final AccessoryMacResolver resolver; + private final DerivedAddressStore store; + + private long startedAtMs = Long.MIN_VALUE; + private long lastRoundMs = Long.MIN_VALUE; + + public WideningSearch(final AccessoryMacResolver resolver, final DerivedAddressStore store) { + this.resolver = resolver; + this.store = store; + } + + /** Notes when the watch began, which is what the warm-up is measured from. */ + public void started(final long nowMs) { + this.startedAtMs = nowMs; + } + + /** Whether a round is due: past the warm-up, and not too soon after the last one. */ + public boolean isDue(final long nowMs) { + if (this.startedAtMs == Long.MIN_VALUE || nowMs - this.startedAtMs < WARM_UP_MS) { + return false; + } + return this.lastRoundMs == Long.MIN_VALUE || nowMs - this.lastRoundMs >= BETWEEN_ROUNDS_MS; + } + + /** + * Derives one chunk further back for one tag that has not been heard, if any needs it. + * + *

One tag per round rather than all of them, so the cost of a round does not depend on + * how many tags somebody owns. + * + * @param lastHeardMsByBeaconId when each tag was last heard; absent means never. + * @return the beacon whose range grew, or null if there was nothing to do. + */ + @Nullable + public String widenOne( + final Map accessoryJsonByBeaconId, + final Map lastHeardMsByBeaconId, + final long nowMs) { + + this.lastRoundMs = nowMs; + + for (final Map.Entry entry : accessoryJsonByBeaconId.entrySet()) { + final String beaconId = entry.getKey(); + + final Long lastHeard = lastHeardMsByBeaconId.get(beaconId); + if (lastHeard != null && nowMs - lastHeard < HEARD_RECENTLY_MS) { + continue; + } + + if (this.widen(beaconId, entry.getValue())) { + return beaconId; + } + } + + return null; + } + + /** True when this tag's stored range actually grew. */ + private boolean widen(final String beaconId, final String accessoryJson) { + final DerivedAddressStore.Derived held = this.store.load(beaconId); + if (held == null) { + // Nothing derived yet at all. The ordinary index rebuild creates it, and widening + // something that does not exist would race with that for no gain. + return false; + } + + final AccessoryMacResolver.IndexRange window = this.resolver.candidateWindow(accessoryJson); + if (window == null) { + return false; + } + + final int floor = Math.max(0, window.getHi() - TARGET_INDICES); + if (held.getLo() <= floor) { + // As far back as this is willing to look. Not a failure: a tag still unheard here has + // been out of step for longer than the addresses are worth keeping for. + return false; + } + + final int to = held.getLo() - 1; + final int from = Math.max(floor, held.getLo() - CHUNK_INDICES); + + final Map derived = this.resolver.addressesBetween( + accessoryJson, from, to); + if (derived == null || derived.isEmpty()) { + // An empty answer for a non-empty range means the derivation failed. Advancing the + // stored range past it anyway would record indices as covered that were never + // derived, and nothing would ever go back for them. + Log.d(TAG, "Nothing derived for beaconId=" + beaconId + " over " + from + ".." + to + + "; leaving the stored range where it is"); + return false; + } + + final Map widened = new HashMap<>(held.getAddresses()); + widened.putAll(derived); + + this.store.save(beaconId, from, held.getHi(), widened); + + Log.i(TAG, "Widened the search for beaconId=" + beaconId + " down to " + from + + " (" + widened.size() + " address(es), floor " + floor + ")"); + return true; + } + + /** The tags worth widening for right now, for a caller that wants to log or test the choice. */ + static Set notHeardRecently( + final Set beaconIds, + final Map lastHeardMsByBeaconId, + final long nowMs) { + + final Set missing = new java.util.HashSet<>(); + for (final String beaconId : beaconIds) { + final Long lastHeard = lastHeardMsByBeaconId.get(beaconId); + if (lastHeard == null || nowMs - lastHeard >= HEARD_RECENTLY_MS) { + missing.add(beaconId); + } + } + return missing; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/data/model/BeaconInformation.java b/app/src/main/java/dev/wander/android/opentagviewer/data/model/BeaconInformation.java index 2c76f48f..cb3d5a74 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/data/model/BeaconInformation.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/data/model/BeaconInformation.java @@ -80,6 +80,15 @@ public class BeaconInformation { * Sourced from the primary file: {@code OwnedBeacons/.plist} */ private final String ownedBeaconPlistRaw; + /** + * Serialized FindMyAccessory/FixedRollingKeyPairAccessory state (JSON) - see + * {@code OwnedBeacon.accessoryJson}. Null for a row imported under FindMy 0.7.6 that has not + * yet been backfilled from {@link #ownedBeaconPlistRaw}. + * + *

What {@code dev.wander.android.opentagviewer.ble} resolves a current BLE MAC address + * from, to recognise this accessory's own advertisement in a scan. + */ + private final String ownedBeaconAccessoryJson; /** * {@code 0} or {@code 1} (?) *

diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/MissingKeystoreKeyException.java b/app/src/main/java/dev/wander/android/opentagviewer/db/MissingKeystoreKeyException.java new file mode 100644 index 00000000..ceea4fa5 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/MissingKeystoreKeyException.java @@ -0,0 +1,27 @@ +package dev.wander.android.opentagviewer.db; + +/** + * Something encrypted is still here, and the key that opens it is not. + * + *

Its own type because it is the one decryption failure that is not a bug. The keys + * live in the Android keystore and the ciphertext lives in this app's data, and those have + * different lifetimes: an OS upgrade, a keystore that got wiped, or a device-transfer tool that + * copied app data - which can never copy keystore keys - all leave exactly this. There is no + * repair; what was written is gone, and the remedy is whatever re-establishes it. + * + *

Everything else that fails to decrypt is unexplained: the key is present, was used to write + * the data, and no longer opens it. That is worth a bug report, and telling the two apart is why + * this class exists rather than one message covering both. + * + *

And the key is never re-created on the decrypt path. It used to be - the same + * "fetch or generate" helper served encrypt and decrypt - so a missing key was quietly replaced + * with a new one that could not open anything already written. That turned a problem which might + * have been momentary into a permanent one, and destroyed the evidence on the way: the alias + * existed again afterwards, so nothing could tell that it had ever gone. + */ +public class MissingKeystoreKeyException extends AppCryptographyException { + + public MissingKeystoreKeyException(final String message) { + super(message); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java b/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java index 4e29e662..9e59de1f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java @@ -29,6 +29,18 @@ public final class UserSettingsDataStore { public static final Preferences.Key ANISETTE_UPGRADE_OFFERED = PreferencesKeys.booleanKey("anisette_upgrade_offered"); public static final Preferences.Key SHOW_APPLE_DEVICES = PreferencesKeys.booleanKey("show_apple_devices"); public static final Preferences.Key ICLOUD_OFFER_MADE = PreferencesKeys.booleanKey("icloud_offer_made"); + public static final Preferences.Key SCAN_IN_BACKGROUND = PreferencesKeys.booleanKey("scan_in_background"); + + /** Seconds of silence before a tag counts as left behind. Absent means the default. */ + public static final Preferences.Key LEFT_BEHIND_AFTER_SECONDS = + PreferencesKeys.intKey("left_behind_after_seconds"); + + /** + * The sound the left-behind alarm plays, as a content URI string. Empty means the system's + * default alarm sound, which is also what an unreadable or since-deleted one falls back to. + */ + public static final Preferences.Key LEFT_BEHIND_SOUND_URI = + PreferencesKeys.stringKey("left_behind_sound_uri"); public static RxDataStore getInstance(Context context) { if (PREFERENCES_DATA_STORE == null) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java index 7c4d9800..3b9e69ea 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java @@ -13,11 +13,14 @@ import java.util.Optional; import java.util.stream.Collectors; +import dev.wander.android.opentagviewer.ble.FindMyAdvertisement; import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; import dev.wander.android.opentagviewer.db.repo.model.BeaconData; import dev.wander.android.opentagviewer.db.repo.model.ImportData; +import dev.wander.android.opentagviewer.db.repo.model.LastSightingData; import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; import dev.wander.android.opentagviewer.db.room.entity.BeaconNamingRecord; +import dev.wander.android.opentagviewer.db.room.entity.LastBleSighting; import dev.wander.android.opentagviewer.db.room.entity.DailyHistoryFetchRecord; import dev.wander.android.opentagviewer.db.room.entity.Import; import dev.wander.android.opentagviewer.db.room.entity.LocationReport; @@ -25,14 +28,18 @@ import dev.wander.android.opentagviewer.db.room.entity.UserBeaconOptions; import dev.wander.android.opentagviewer.db.util.BeaconCombinerUtil; import dev.wander.android.opentagviewer.python.AccessoryRequest; +import dev.wander.android.opentagviewer.python.AppDependencies; import dev.wander.android.opentagviewer.python.ChaquopyPlistToAccessoryJsonConverter; import dev.wander.android.opentagviewer.python.FetchResult; import dev.wander.android.opentagviewer.python.icloud.AccessoryRecords; import dev.wander.android.opentagviewer.util.parse.NamingRecordEditor; import dev.wander.android.opentagviewer.python.PlistToAccessoryJsonConverter; import dev.wander.android.opentagviewer.util.BeaconLocationReportHasher; +import dev.wander.android.opentagviewer.util.LocalFixWorthKeeping; +import dev.wander.android.opentagviewer.util.parse.AccessoryAlignment; import dev.wander.android.opentagviewer.util.parse.KeyAlignmentPlist; import dev.wander.android.opentagviewer.util.rx.ScanOrder; +import dev.wander.android.opentagviewer.util.rx.SlowFirstFetch; import dev.wander.android.opentagviewer.util.rx.WideScanBackoff; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Observable; @@ -483,6 +490,39 @@ public Observable> neverScanned() { .subscribeOn(Schedulers.io()); } + /** + * Whether fetching these accessories means a long key search. + * + *

Asked of the requests that were actually built rather than of everything on screen: the + * scheduled fetch skips tags that are ignored or backing off, and an unaligned tag that is + * not being fetched should not put a banner up about a wait that is not happening. + * + *

The same {@code observedAtMillis} XPath the scan ordering uses, over a plist already in + * memory - see {@link #dueForAScheduledScan}, which explains why this is read rather than + * stored in a column. + * + * @see dev.wander.android.opentagviewer.util.rx.SlowFirstFetch for the arithmetic. + */ + public Observable aFetchOfTheseWouldBeSlow(final List requests) { + return Observable.fromCallable(() -> { + final var dao = db.ownedBeaconDao(); + final List alignedAt = new ArrayList<>(); + + for (final AccessoryRequest request : requests) { + final OwnedBeacon row = dao.getById(request.getBeaconId()); + // **Both, and the later one wins.** The export's record is frozen at import; + // the accessory state carries the alignment the last fetch actually reached. + // Reading only the record showed the banner on every refresh for anybody whose + // export was more than a week old, however recently their tags had updated. + alignedAt.add(row == null ? null : SlowFirstFetch.laterOf( + AccessoryAlignment.alignedAtMillis(row.accessoryJson), + KeyAlignmentPlist.observedAtMillis(row.alignmentPlist))); + } + + return SlowFirstFetch.isLikely(alignedAt, System.currentTimeMillis()); + }).subscribeOn(Schedulers.io()); + } + public Observable> toAccessoryRequests(Map beaconIdToPlistFallback) { return Observable.fromCallable(() -> { if (beaconIdToPlistFallback.isEmpty()) { @@ -527,6 +567,283 @@ public Observable> toAccessoryRequests(MapA BLE sighting is worth the same as a decrypted location report, and is persisted + * the same way: FindMy.py's {@code update_alignment} is how it is told about either, and + * {@code accessory_json} is where the result lives. Without this the twelve-hour candidate + * range that found the tag is re-derived from scratch on the next scan; with it, the next + * scan derives three keys. + * + *

Failure is swallowed on purpose. This runs after a sound has already played (or + * failed to), and nothing the user asked for depends on it - the cost of losing it is a + * wider search next time, not a broken feature. It is emphatically not worth turning a + * successful ring into an error toast. + */ + public Completable recordAccessorySighting( + final String beaconId, final String mac, final long seenAtUnixMs, + final Integer hintIndex) { + return Completable.fromRunnable(() -> { + final var dao = db.ownedBeaconDao(); + final OwnedBeacon row = dao.getById(beaconId); + + if (row == null || row.accessoryJson == null) { + Log.d(TAG, "Nothing to align for beaconId=" + beaconId + " - no accessory_json"); + return; + } + + final String updated = AppDependencies.accessoryMacResolver() + .recordSeen(row.accessoryJson, mac, seenAtUnixMs, hintIndex); + + if (updated == null) { + // Also the ordinary outcome of a secondary-key-only match, not just a failure - + // see AccessoryMacResolver#recordSeen. Nothing to log as a problem here. + return; + } + + dao.updateAccessoryJson(beaconId, updated); + Log.d(TAG, "Aligned beaconId=" + beaconId + " from a Bluetooth sighting"); + }).subscribeOn(Schedulers.io()); + } + + /** + * Keep what a tag just told this phone directly, replacing whatever it last said. + * + *

Why it outlives the sighting that produced it. The reading is shown live while the + * tag is audible and then ages out, because a signal strength or a "nearby" badge stops being + * true the moment the tag is carried off. A battery level does not: a tag that read "low" an + * hour ago is still low, and for a user with no Apple device there is no other source that + * will ever say so - see {@link LastBleSighting}. So the live display expires and what it + * said is kept. + * + *

Takes the fields of a sighting rather than a sighting object, because the one it would + * take lives in the {@code ble} package and carries a live RSSI this deliberately does not + * store. A parameter list is the honest signature for "these are the parts worth keeping". + * + *

Failure is swallowed, like every other write on this path. Losing a reading costs + * a screen one row until the tag is next heard, and this runs behind a passive scan the user + * did not ask for. Nothing they did may fail because of it. + */ + public Completable storeLastSighting( + final String beaconId, + final FindMyAdvertisement.BatteryLevel batteryLevel, + final int statusByte, + final long heardAtUnixMs) { + return Completable.fromRunnable(() -> { + db.lastBleSightingDao().insert(LastBleSighting.builder() + .beaconId(beaconId) + .heardAt(heardAtUnixMs) + .batteryLevel(batteryLevel.name()) + .statusByte(statusByte) + .build()); + }).subscribeOn(Schedulers.io()); + } + + /** + * Write down where this phone was when it heard the tag, as a location report of its own. + * + *

The same table as Apple's reports, on purpose. The map marker, the "last + * updated" line, the navigate button, the history list and the CSV export all read from + * there; a separate table would mean teaching every one of them about a second source. The + * {@code provenance} column is what keeps the two distinguishable - see + * {@link LocationReport#provenance}. + * + *

It is the phone's position, not the tag's. Hearing a Find My advertisement puts + * the tag within roughly ten metres, which is why {@code horizontal_accuracy} is filled from + * the fix's own accuracy rather than invented: for a reader, and for anything that compares + * two reports, that is the honest width of the claim. It is also usually an order of + * magnitude better than a network report, which describes where a stranger's iPhone thought + * it was. + * + *

Not every sighting earns a row. {@link LocalFixWorthKeeping} decides, because + * sightings arrive far faster than positions are worth keeping - a tag beside somebody all + * evening would otherwise write hundreds of rows describing one spot, each reverse-geocoded + * when shown. + * + *

Failure is swallowed like every other write on the sighting path: this runs behind a + * passive scan nobody asked for, and nothing the user did may fail because of it. + * + * @return the report that was written, or empty when this sighting did not earn a row - so a + * caller can log or test the decision, and can also draw what was just recorded + * without reading it back. + */ + public Observable> recordLocalSighting( + final String beaconId, + final double latitude, + final double longitude, + final long accuracyMetres, + final long statusByte, + final long heardAtUnixMs) { + + return Observable.fromCallable(() -> { + final var dao = db.locationReportDao(); + final LocationReport last = dao.getLastLocalFor(beaconId); + + final boolean keep = LocalFixWorthKeeping.worthKeeping( + last == null ? null : last.latitude, + last == null ? null : last.longitude, + last == null ? null : last.timestamp, + latitude, longitude, heardAtUnixMs); + + if (!keep) { + return Optional.empty(); + } + + // Built as the shared model first so the id comes out of the same hasher the network + // path uses. It folds in the beacon, the timestamp, the coordinates, the status and + // the description, so two sightings of the same tag at the same moment and place + // collapse to one row instead of accumulating - and a local row can never collide + // with an Apple one, because no Apple report carries this description. + final BeaconLocationReport report = BeaconLocationReport.builder() + .publishedAt(heardAtUnixMs) + .description(LOCAL_REPORT_DESCRIPTION) + .timestamp(heardAtUnixMs) + // Apple's confidence byte is a number this app deliberately does not + // interpret - see LocationReportFields. There is nothing honest to put here, + // so it stays zero rather than borrowing a scale that means something else. + .confidence(0) + .latitude(latitude) + .longitude(longitude) + .horizontalAccuracy(accuracyMetres) + .status(statusByte) + .build(); + + dao.insertAll(LocationReport.builder() + .hashId(BeaconLocationReportHasher.getSha256HashFor(beaconId, report)) + .beaconId(beaconId) + .publishedAt(report.getPublishedAt()) + .description(report.getDescription()) + .timestamp(report.getTimestamp()) + .confidence(report.getConfidence()) + .latitude(report.getLatitude()) + .longitude(report.getLongitude()) + .horizontalAccuracy(report.getHorizontalAccuracy()) + .status(report.getStatus()) + .lastUpdate(System.currentTimeMillis()) + .provenance(LocationReport.PROVENANCE_LOCAL) + .build()); + + Log.d(TAG, "Wrote a local position for beaconId=" + beaconId); + + // Handed back rather than announced as a bare boolean so a caller that draws a map + // can put this on it without rebuilding the same report from the same inputs and + // risking a second, subtly different definition of what a local report looks like. + return Optional.of(report); + }).subscribeOn(Schedulers.io()); + } + + /** + * What a locally heard report says in its description field. + * + *

Apple fills this with its own text, so a fixed string here both labels the row in the + * debug panel and guarantees the hash of a local row can never match an Apple one. + */ + public static final String LOCAL_REPORT_DESCRIPTION = "Heard over Bluetooth"; + + /** + * The tags whose owner wants to be warned when they are left behind. + * + *

Returned as the permissions rather than the exceptions because null - nobody has + * decided - means no. A tag has to be asked for by name: most tags a person owns are + * routinely left somewhere on purpose, and a feature that alarms about all of them until + * told otherwise gets switched off wholesale after the second false alarm. + * + *

Only an explicit true counts, so a tag with no options row at all is silent - which is + * every tag until somebody flips the switch. + */ + public Observable> getBeaconsWithAlertsOn() { + return Observable.fromCallable(() -> { + final Set on = new HashSet<>(); + + for (final UserBeaconOptions options : db.userBeaconOptionsDao().getAll()) { + if (Boolean.TRUE.equals(options.alertOnSeparation)) { + on.add(options.beaconId); + } + } + + return on; + }).subscribeOn(Schedulers.io()); + } + + /** Store whether being left behind is worth a noise for this tag. */ + public Completable storeAlertOnSeparation(final String beaconId, final boolean alert) { + return Completable.fromRunnable(() -> + db.userBeaconOptionsDao().storeAlertOnSeparation( + beaconId, alert, System.currentTimeMillis())) + .subscribeOn(Schedulers.io()); + } + + /** + * Whether being left behind is worth a noise for this tag. Null - undecided - reads as no. + */ + public Observable getAlertOnSeparation(final String beaconId) { + return Observable.fromCallable(() -> { + final UserBeaconOptions options = db.userBeaconOptionsDao().getById(beaconId); + return options != null && Boolean.TRUE.equals(options.alertOnSeparation); + }).subscribeOn(Schedulers.io()); + } + + /** + * The key material for every tag worth listening for, keyed by beacon. + * + *

For {@code NearbyScanService}, which has no screen to inherit a loaded model from and + * so has to ask. The screens build the same map out of what they already hold. + * + *

Retired tags are left out, given-up ones are not. A retired tag is one that has + * left the account, so there is nothing to listen for. A tag the network gave up on is the + * opposite case: it stopped being findable over Apple's network, and hearing it directly is + * exactly what could still find it. + * + *

A tag with no {@code accessory_json} is skipped rather than passed on: without key + * material there is nothing to derive an address from, and the watcher would only discard it. + */ + public Observable> getAccessoryJsonByBeaconId() { + return Observable.fromCallable(() -> { + final Map byBeaconId = new HashMap<>(); + + for (final OwnedBeacon beacon : db.ownedBeaconDao().getAll()) { + if (beacon.isRemoved || beacon.accessoryJson == null + || beacon.accessoryJson.isEmpty()) { + continue; + } + byBeaconId.put(beacon.id, beacon.accessoryJson); + } + + return byBeaconId; + }).subscribeOn(Schedulers.io()); + } + + /** + * The last thing heard from this tag over Bluetooth, or empty if it never has been. + * + *

Empty is also the answer for a battery level this version does not recognise. A + * row written by a later build that knows a fifth state would otherwise have to be mapped + * onto one of the four here, and every choice available is a wrong reading presented as a + * right one. Showing nothing is the only honest option, and the raw byte is still in the row + * for anyone debugging it. + */ + public Observable> getLastSighting(final String beaconId) { + return Observable.fromCallable(() -> { + final LastBleSighting row = db.lastBleSightingDao().getById(beaconId); + if (row == null) { + return Optional.empty(); + } + + try { + return Optional.of(new LastSightingData( + row.heardAt, + FindMyAdvertisement.BatteryLevel.valueOf(row.batteryLevel), + row.statusByte)); + } catch (final IllegalArgumentException e) { + Log.w(TAG, "Ignoring an unrecognised stored battery level '" + row.batteryLevel + + "' for beaconId=" + beaconId); + return Optional.empty(); + } + }).subscribeOn(Schedulers.io()); + } + /** * Persist a {@link FetchResult} from {@code PythonAppleService}: location reports * go to the cache (delegating to {@link #storeToLocationCache}), and the freshly @@ -640,6 +957,10 @@ public Observable>> storeToLocationCache( .horizontalAccuracy(locationReport.getHorizontalAccuracy()) .status(locationReport.getStatus()) .lastUpdate(now) + // Everything arriving here was decrypted from Apple's + // network. The local path writes its own rows and sets this + // itself - see recordLocalSighting. + .provenance(LocationReport.PROVENANCE_APPLE) .build() )) .toArray(LocationReport[]::new); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/KeychainMembershipRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/KeychainMembershipRepository.java index c2a66edf..72d7599a 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/KeychainMembershipRepository.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/KeychainMembershipRepository.java @@ -16,6 +16,7 @@ import dev.wander.android.opentagviewer.python.icloud.KeychainMembership; import dev.wander.android.opentagviewer.db.AppCryptographyException; +import dev.wander.android.opentagviewer.db.MissingKeystoreKeyException; import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Observable; @@ -55,37 +56,110 @@ public KeychainMembershipRepository( this.cryptography = cryptography; } - /** The membership, or empty when this app has not joined - which is the ordinary first run. */ - public Observable> get() { + /** + * What this app holds, told apart from what it can use. + * + *

"Nothing stored" and "stored but unreadable" are different situations with the same + * shape. Collapsing them into an empty Optional is right for most callers - either way + * there is no membership to use - but it makes the app behave as though the account was + * never connected, which is wrong in a way the user can see: they are offered a first-time + * setup for something they already did, and nothing anywhere says why. + */ + public enum MembershipState { + /** Never joined. The ordinary first run. */ + NONE, + /** Joined, and the keys are usable. */ + HELD, + /** + * Joined, and the keystore key that opened it is gone. + * + *

Explainable, and not this app's fault. The keys live in the Android keystore + * and the ciphertext lives in app data, and those have different lifetimes - an OS + * upgrade, a wiped keystore, a device-transfer tool that copied app data and could never + * copy keystore keys. Nothing here can recover it; the remedy is to join again. + * + *

Reported rather than repaired: deleting the row on a failure would throw away a + * membership that a momentary keystore problem had made unreadable for a moment. + */ + KEYS_GONE, + /** + * Joined, the key is right there, and it still does not open the data. + * + *

That is not explainable, so it is a bug. The key present and the ciphertext + * present and the two not matching means something wrote or stored it wrongly, and the + * user is owed a bug report rather than an apology - see how {@code MapsActivity} routes + * this one to the report screen while {@link #KEYS_GONE} gets an explanation. + */ + UNREADABLE, + } + + /** + * Which of the three situations this device is in. + * + *

Prefer {@link #get()} where only a usable membership matters; use this where the + * difference between "never connected" and "connected but broken" changes what the user is + * told. + */ + public Observable state() { return Observable.fromPublisher(this.store.data()).map(preferences -> { final byte[] encrypted = preferences.get(KEYCHAIN_MEMBERSHIP); if (encrypted == null) { - return Optional.empty(); + return MembershipState.NONE; } try { - final byte[] plain = this.cryptography.decrypt( - AppCryptographyUtil.AppEncryptedData.fromFlattened(encrypted), - KEYSTORE_ALIAS_KEYCHAIN); - final JSONObject json = new JSONObject(new String(plain, StandardCharsets.UTF_8)); - - return Optional.of(new KeychainMembership( - json.getString(FIELD_PEER), - json.getString(FIELD_ENTROPY), - json.getString(FIELD_PASSCODE), - json.optString(FIELD_LABEL, ""), - json.optInt(FIELD_SHARES, 0))); - } catch (Exception e) { - // **Reported as absent rather than thrown.** A membership that cannot be read is - // a membership this app cannot use, and the recovery is the same as never having - // joined: ask for a passcode and join again. Throwing here would take down the - // screen instead, on a path the user cannot do anything about. - Log.e(TAG, "The stored keychain membership could not be read", e); - return Optional.empty(); + this.decode(encrypted); + return MembershipState.HELD; + } catch (final MissingKeystoreKeyException keyIsGone) { + Log.w(TAG, "The keystore key for the membership is gone, so it cannot be read", + keyIsGone); + return MembershipState.KEYS_GONE; + } catch (final Exception unexplained) { + Log.e(TAG, "The membership is stored and its key is present, and it still does" + + " not decrypt", unexplained); + return MembershipState.UNREADABLE; } }); } + /** The membership, or empty when this app has not joined - which is the ordinary first run. */ + public Observable> get() { + return Observable.fromPublisher(this.store.data()).map(this::readFrom); + } + + /** Decrypt and parse, or throw. {@link #state()} is the caller that wants to know why. */ + private KeychainMembership decode(final byte[] encrypted) throws Exception { + final byte[] plain = this.cryptography.decrypt( + AppCryptographyUtil.AppEncryptedData.fromFlattened(encrypted), + KEYSTORE_ALIAS_KEYCHAIN); + final JSONObject json = new JSONObject(new String(plain, StandardCharsets.UTF_8)); + + return new KeychainMembership( + json.getString(FIELD_PEER), + json.getString(FIELD_ENTROPY), + json.getString(FIELD_PASSCODE), + json.optString(FIELD_LABEL, ""), + json.optInt(FIELD_SHARES, 0)); + } + + private Optional readFrom(final Preferences preferences) { + final byte[] encrypted = preferences.get(KEYCHAIN_MEMBERSHIP); + if (encrypted == null) { + return Optional.empty(); + } + + try { + return Optional.of(this.decode(encrypted)); + } catch (Exception e) { + // **Reported as absent rather than thrown.** A membership that cannot be read is + // a membership this app cannot use, and the recovery is the same as never having + // joined: ask for a passcode and join again. Throwing here would take down the + // screen instead, on a path the user cannot do anything about. + Log.e(TAG, "The stored keychain membership could not be read", e); + return Optional.empty(); + } + } + /** * Store a membership, and refuse to report success unless it is really stored. * diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java index 92192da0..a8920e8f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java @@ -8,7 +8,10 @@ import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.ENABLE_DEBUG_DATA; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.ICLOUD_OFFER_MADE; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.LANGUAGE; +import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.LEFT_BEHIND_AFTER_SECONDS; +import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.LEFT_BEHIND_SOUND_URI; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.MAP_PROVIDER; +import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.SCAN_IN_BACKGROUND; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.SHOW_APPLE_DEVICES; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.USE_DARK_THEME; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.USE_SYSTEM_COLORS; @@ -43,7 +46,10 @@ public UserSettings getUserSettings() { String anisetteApkUri = settings.get(ANISETTE_APK_URI); Boolean anisetteUpgradeOffered = settings.get(ANISETTE_UPGRADE_OFFERED); Boolean showAppleDevices = settings.get(SHOW_APPLE_DEVICES); + Boolean scanInBackground = settings.get(SCAN_IN_BACKGROUND); Boolean icloudOfferMade = settings.get(ICLOUD_OFFER_MADE); + Integer leftBehindAfterSeconds = settings.get(LEFT_BEHIND_AFTER_SECONDS); + String leftBehindSoundUri = settings.get(LEFT_BEHIND_SOUND_URI); return UserSettings.builder() .anisetteServerUrl(anisetteServerUrl) @@ -57,7 +63,10 @@ public UserSettings getUserSettings() { .anisetteApkUri(anisetteApkUri) .anisetteUpgradeOffered(anisetteUpgradeOffered) .showAppleDevices(showAppleDevices) + .scanInBackground(scanInBackground) .icloudOfferMade(icloudOfferMade) + .leftBehindAfterSeconds(leftBehindAfterSeconds) + .leftBehindSoundUri(leftBehindSoundUri) .build(); }).subscribeOn(Schedulers.io()) @@ -95,6 +104,17 @@ public Completable storeUserSettings(UserSettings userSettings) { // Null would throw; an empty string reads back as "no key supplied". mutablePreferences.set(AMAP_API_KEY, userSettings.getAmapApiKey() == null ? "" : userSettings.getAmapApiKey()); + + // Zero rather than absent for "never chosen": the key is an int key and cannot + // hold null, and resolveLeftBehindAfterSeconds already reads a non-positive value + // as the default. + mutablePreferences.set(LEFT_BEHIND_AFTER_SECONDS, + userSettings.getLeftBehindAfterSeconds() == null + ? 0 : userSettings.getLeftBehindAfterSeconds()); + // Null would throw; an empty string reads back as "use the default alarm sound". + mutablePreferences.set(LEFT_BEHIND_SOUND_URI, + userSettings.getLeftBehindSoundUri() == null + ? "" : userSettings.getLeftBehindSoundUri()); // An empty string means "not chosen", which is not the same as either mode - see // UserSettings.anisetteMode. Writing "local" here for somebody who never chose // would move an existing session onto a different machine identity. @@ -107,6 +127,7 @@ public Completable storeUserSettings(UserSettings userSettings) { // Null reads as off, which is the intended default - the app shows only what it can // actually keep up to date. See UserSettings.showAppleDevices. mutablePreferences.set(SHOW_APPLE_DEVICES, userSettings.shouldShowAppleDevices()); + mutablePreferences.set(SCAN_IN_BACKGROUND, userSettings.shouldScanInBackground()); // Once true this never goes back to false: somebody who dismissed the offer has // answered it, and asking again is how a prompt becomes something people close // without reading. See UserSettings.icloudOfferMade. diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/LastSightingData.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/LastSightingData.java new file mode 100644 index 00000000..5332ccb4 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/LastSightingData.java @@ -0,0 +1,29 @@ +package dev.wander.android.opentagviewer.db.repo.model; + +import dev.wander.android.opentagviewer.ble.FindMyAdvertisement; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * What a tag last told this phone directly, and when it said it. + * + *

Always older than now, and possibly much older - a tag left in a coat pocket says nothing + * for as long as it is out of range, and this is the last thing it managed to say before that. + * Anything showing any of it must show the age with it, which is why the timestamp is not + * optional here. + * + *

The battery level is all a sighting carries today. See {@code LastBleSighting} for why this + * is named for the sighting rather than for that one field. + */ +@AllArgsConstructor +@Getter +public final class LastSightingData { + + /** When the advertisement was heard. */ + private final long heardAtMs; + + private final FindMyAdvertisement.BatteryLevel batteryLevel; + + /** The status byte the level came out of, kept for bug reports. See the entity. */ + private final int statusByte; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java index 2aa50b4e..9db79b66 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java @@ -118,9 +118,76 @@ public class UserSettings { */ private Boolean icloudOfferMade; + /** + * Whether to keep listening for the user's tags while the app is closed. + * + *

Off unless somebody turns it on, and this one changes what the app is. Without + * it the radio only listens while a screen is open, which makes this a display feature: it + * tells you what is near you while you are looking. With it the app runs a foreground + * service with a permanent notification, listens continuously, and writes down where your + * tags were heard - which is a recording feature, and one this app's users have specific + * reasons to want to opt into rather than receive. + * + *

It is also what makes the local position history worth having: the case a history + * answers is "where did I leave it", and the app is shut at exactly that moment. + * + *

Null reads as off. See {@link #shouldScanInBackground()}. + */ + private Boolean scanInBackground; + + /** + * How many seconds of silence make a tag count as left behind. + * + *

Adjustable because the right answer is about the person, not the tag. Somebody who + * wants to be caught before the end of the street wants a few seconds and will accept the + * occasional check that finds the tag still there; somebody who puts their bag down a lot + * wants a minute and no interruptions. Neither is wrong, and no single number is right for + * both. + * + *

Null means {@link #LEFT_BEHIND_AFTER_SECONDS_DEFAULT}. See + * {@link #resolveLeftBehindAfterSeconds()}, which also enforces the floor - below it the + * check cadence, not this number, decides when the alert arrives, and a setting that + * silently does nothing is worse than one that will not go that low. + */ + private Integer leftBehindAfterSeconds; + + /** + * The alarm sound, as a content URI string, or null/empty for the system default alarm. + * + *

Held as the URI the ringtone picker handed back rather than anything resolved: the + * sound behind it can be deleted or live on a volume that is not mounted, so it is read + * defensively at the moment it is played and falls back to the default there. + */ + private String leftBehindSoundUri; + + /** What a tag's silence has to outlast before it is worth a targeted check. */ + public static final int LEFT_BEHIND_AFTER_SECONDS_DEFAULT = 30; + + /** + * The shortest silence worth offering. + * + *

A tag advertises every one to three seconds, but a scan at a duty cycle below full + * leaves gaps of its own, and the verification scan that follows takes six seconds on its + * own. Under ten there is nothing left for the number to control. + */ + public static final int LEFT_BEHIND_AFTER_SECONDS_MIN = 10; + + /** Beyond this the tag is somewhere else entirely and the alert has missed its moment. */ + public static final int LEFT_BEHIND_AFTER_SECONDS_MAX = 300; + public static final String ANISETTE_LOCAL = "local"; public static final String ANISETTE_REMOTE = "remote"; + /** The configured silence in seconds, defaulted and clamped to what the check can honour. */ + public int resolveLeftBehindAfterSeconds() { + if (this.leftBehindAfterSeconds == null || this.leftBehindAfterSeconds <= 0) { + return LEFT_BEHIND_AFTER_SECONDS_DEFAULT; + } + + return Math.max(LEFT_BEHIND_AFTER_SECONDS_MIN, + Math.min(LEFT_BEHIND_AFTER_SECONDS_MAX, this.leftBehindAfterSeconds)); + } + public boolean hasDarkThemeEnabled() { return this.useDarkTheme == Boolean.TRUE; } @@ -191,6 +258,14 @@ public boolean shouldShowAppleDevices() { return this.showAppleDevices == Boolean.TRUE; } + /** + * Whether to keep listening while the app is closed - see {@link #scanInBackground}. Null + * means nobody has turned it on, which is off. + */ + public boolean shouldScanInBackground() { + return this.scanInBackground == Boolean.TRUE; + } + /** * Whether to offer connecting an iCloud account. * diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java index cdf7f2a6..2184dc66 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java @@ -10,12 +10,14 @@ import androidx.sqlite.db.SupportSQLiteDatabase; import dev.wander.android.opentagviewer.db.room.dao.BeaconNamingRecordDao; +import dev.wander.android.opentagviewer.db.room.dao.LastBleSightingDao; import dev.wander.android.opentagviewer.db.room.dao.DailyHistoryFetchRecordDao; import dev.wander.android.opentagviewer.db.room.dao.ImportDao; import dev.wander.android.opentagviewer.db.room.dao.LocationReportDao; import dev.wander.android.opentagviewer.db.room.dao.OwnedBeaconDao; import dev.wander.android.opentagviewer.db.room.dao.UserBeaconOptionsDao; import dev.wander.android.opentagviewer.db.room.entity.BeaconNamingRecord; +import dev.wander.android.opentagviewer.db.room.entity.LastBleSighting; import dev.wander.android.opentagviewer.db.room.entity.DailyHistoryFetchRecord; import dev.wander.android.opentagviewer.db.room.entity.Import; import dev.wander.android.opentagviewer.db.room.entity.LocationReport; @@ -29,9 +31,10 @@ OwnedBeacon.class, LocationReport.class, DailyHistoryFetchRecord.class, - UserBeaconOptions.class + UserBeaconOptions.class, + LastBleSighting.class }, - version = 6 + version = 9 ) public abstract class OpenTagViewerDatabase extends RoomDatabase { private static OpenTagViewerDatabase INSTANCE = null; @@ -129,6 +132,69 @@ public void migrate(@NonNull SupportSQLiteDatabase db) { } }; + /** + * v6 → v7: adds {@code LastBleSighting}, the last thing this phone heard each tag say over + * Bluetooth - today its battery level, and whatever else a sighting turns out to be worth + * keeping later. + * + *

A new table rather than a column, for the reasons on {@link LastBleSighting} - briefly, + * neither of the tables that already hold something per beacon is a place a measurement taken + * by this phone belongs. + * + *

Creating an empty table changes nothing for an existing install: every tag simply has no + * sighting until the next time its advertisement is actually heard, which is the honest + * state. Nothing is backfilled, and in particular the accessory record's own battery field is + * not copied in - that value is Apple's, is stale or unset for exactly the users this table + * is for, and would arrive here presented as something this phone had heard. + */ + public static final Migration MIGRATION_6_7 = new Migration(6, 7) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL("CREATE TABLE IF NOT EXISTS `LastBleSighting` (" + + "`beacon_id` TEXT NOT NULL, " + + "`heard_at` INTEGER NOT NULL, " + + "`battery_level` TEXT NOT NULL, " + + "`status_byte` INTEGER NOT NULL, " + + "PRIMARY KEY(`beacon_id`), " + + "FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`)" + + " ON UPDATE CASCADE ON DELETE CASCADE )"); + } + }; + + /** + * v7 → v8: adds {@code provenance} to {@code LocationReport}, saying whether a row came from + * Apple's network or from this phone hearing the tag itself. + * + *

Both kinds live in this table on purpose - everything that draws a tag reads from here - + * but they are not the same claim, and the history export hands somebody a file in which + * they would otherwise be indistinguishable. See {@link LocationReport#provenance}. + * + *

Additive, with a default of {@code apple}. That is not a fallback but the truth for + * every existing row: local rows could not exist before this column did. + */ + public static final Migration MIGRATION_7_8 = new Migration(7, 8) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE LocationReport" + + " ADD COLUMN provenance TEXT NOT NULL DEFAULT 'apple'"); + } + }; + + /** + * v8 → v9: adds {@code alert_on_separation} to {@code UserBeaconOptions}, the per-tag answer + * to whether being left behind is worth a noise. + * + *

Additive and nullable rather than defaulted, and null reads as no - see + * {@link UserBeaconOptions#alertOnSeparation}. Every existing row is null, which is correct: + * nobody has asked for an alert on a tag that could not alert yet. + */ + public static final Migration MIGRATION_8_9 = new Migration(8, 9) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE UserBeaconOptions ADD COLUMN alert_on_separation INTEGER"); + } + }; + /** * The database file's name, which is also read directly - see * {@code OpenAirTagApplication.isFirstRun()}, which uses the file's presence to tell a new @@ -145,7 +211,8 @@ public static OpenTagViewerDatabase getInstance(Context context) { OpenTagViewerDatabase.class, DATABASE_NAME) .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, - MIGRATION_5_6) + MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, + MIGRATION_8_9) .build(); } @@ -158,4 +225,5 @@ public static OpenTagViewerDatabase getInstance(Context context) { public abstract LocationReportDao locationReportDao(); public abstract DailyHistoryFetchRecordDao dailyHistoryFetchRecordDao(); public abstract UserBeaconOptionsDao userBeaconOptionsDao(); + public abstract LastBleSightingDao lastBleSightingDao(); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/LastBleSightingDao.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/LastBleSightingDao.java new file mode 100644 index 00000000..159f9253 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/LastBleSightingDao.java @@ -0,0 +1,33 @@ +package dev.wander.android.opentagviewer.db.room.dao; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; + +import dev.wander.android.opentagviewer.db.room.entity.LastBleSighting; + +@Dao +public interface LastBleSightingDao { + @Query("SELECT * FROM LastBleSighting WHERE beacon_id = :beaconId") + LastBleSighting getById(String beaconId); + + /** + * Store this sighting, replacing whatever the tag last said. + * + *

{@code REPLACE} is safe here in a way it is not on other tables. It deletes the + * conflicting row before inserting, and on {@code OwnedBeacons} or {@code UserBeaconOptions} + * that delete either cascades into location history or throws away a nickname - see the long + * note on {@code UserBeaconOptionsDao.storeArrangement}. Nothing references this table, and + * every column is written on every insert, so there is nothing for the delete to take with + * it. It also works on the SQLite that ships with API 24, which the {@code ON CONFLICT DO + * UPDATE} form does not. + * + *

Worth revisiting if a column is ever added that not every sighting can fill. A + * position, for instance, would be absent whenever the phone had no fix - and with + * {@code REPLACE} a sighting carrying no position would erase the last one that did. At that + * point this wants to become the insert-then-update pair that table uses. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + void insert(LastBleSighting sighting); +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/LocationReportDao.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/LocationReportDao.java index b88f20c2..c462b1d5 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/LocationReportDao.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/LocationReportDao.java @@ -53,6 +53,18 @@ public interface LocationReportDao { @Query("SELECT MAX(timestamp) FROM LocationReport WHERE beacon_id = :beaconId") Long newestReportTimeFor(String beaconId); + /** + * The newest report this phone wrote for one tag, or null if it has never heard it. + * + *

Scoped to local rows because it decides whether the next sighting is worth keeping - + * see {@code LocalFixWorthKeeping}. An Apple report says nothing about that: it describes + * where somebody else's iPhone was, so a fresh one would silently suppress the local row + * that is the more precise of the two. + */ + @Query("SELECT * FROM LocationReport WHERE beacon_id = :beaconId AND provenance = 'local'" + + " ORDER BY timestamp DESC LIMIT 1") + LocationReport getLastLocalFor(String beaconId); + @Insert(onConflict = OnConflictStrategy.REPLACE) void insertAll(LocationReport... locationReports); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/UserBeaconOptionsDao.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/UserBeaconOptionsDao.java index cf57df13..df7b231a 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/UserBeaconOptionsDao.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/UserBeaconOptionsDao.java @@ -78,9 +78,28 @@ default void storeArrangement(final Map positions, final long n } } + /** + * Store whether this tag is worth a noise when it is left behind. + * + *

Insert-then-update rather than a replace, for the reason spelled out on + * {@link #storeArrangement}: most tags have no row here, and {@code INSERT OR REPLACE} would + * delete the nickname and the arrangement on the way past. + */ + @Transaction + default void storeAlertOnSeparation( + final String beaconId, final boolean alert, final long now) { + + this.createIfAbsent(beaconId, now); + this.setAlertOnSeparation(beaconId, alert, now); + } + + @Query("UPDATE UserBeaconOptions SET alert_on_separation = :alert, last_update = :now" + + " WHERE beacon_id = :beaconId") + void setAlertOnSeparation(String beaconId, boolean alert, long now); + /** A row to hang a position on, for a tag the user has never renamed. See above. */ @Query("INSERT OR IGNORE INTO UserBeaconOptions (beacon_id, last_update, ui_name, ui_emoji," - + " ui_order) VALUES (:beaconId, :now, NULL, NULL, NULL)") + + " ui_order, alert_on_separation) VALUES (:beaconId, :now, NULL, NULL, NULL, NULL)") void createIfAbsent(String beaconId, long now); /** Writes only the position, leaving the nickname and emoji exactly as they are. */ diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LastBleSighting.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LastBleSighting.java new file mode 100644 index 00000000..5d21082d --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LastBleSighting.java @@ -0,0 +1,101 @@ +package dev.wander.android.opentagviewer.db.room.entity; + +import androidx.annotation.NonNull; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.ForeignKey; +import androidx.room.PrimaryKey; + +import lombok.AllArgsConstructor; +import lombok.Builder; + +/** + * The last thing this phone heard a tag say over Bluetooth, and when it heard it. + * + *

Named for the sighting, not for the battery, although the battery is all it holds + * today. The row is "what the tag last told us directly", and the battery level is one field + * of that. Anything else worth keeping from a sighting - the position the phone was at when it + * heard it is the obvious candidate, and the one already asked for in PR #139 - is another column + * here rather than another table, and a plain additive migration. Naming the table after its + * first column would have meant a rename, and renaming a table is the one migration SQLite makes + * genuinely awkward. + * + *

Why any of it is kept. The battery value on the accessory record comes from Apple's + * devices as they walk past the tag, so for anyone without one it is either years old or never + * written at all - see {@code BatteryLevelDescription}, and note that both of the real tags this + * was developed against still report 0, "not yet reported". For those users the advertisement is + * the only source there is. Keeping what it said means a tag heard this morning can still say + * what it said this morning, instead of the screen going blank the moment the tag is out of + * earshot. + * + *

Only what stays true is kept. A battery level heard an hour ago is still roughly the + * battery level; a signal strength heard an hour ago is about a distance that no longer exists, + * so the RSSI on the sighting is deliberately not stored. Persisting it would invite showing it, + * and {@code NearbyTagLabel} explains at length why even a live RSSI may not be presented as a + * distance. + * + *

One row per tag, overwritten, not a history. Every advertisement carries the same two + * bits, so a log of them would be thousands of rows saying "full" to answer a question that only + * ever needs the most recent one. If a genuine sighting history is ever built - as a local + * alternative to Apple's location reports - it is a different shape, many rows per tag, and it + * wants its own table; this one would stay as the cheap "what is the latest" lookup. + * + *

Its own table rather than a column elsewhere. {@code UserBeaconOptions} is what the + * owner has decided about a tag and an account refresh is careful never to touch it, which is + * the wrong company for a measurement. {@code OwnedBeacons} is the cache of what Apple said, + * rewritten from the account, and a reading taken by this phone is not Apple's to overwrite. + */ +@Builder +@AllArgsConstructor +@Entity( + tableName = "LastBleSighting", + foreignKeys = { + @ForeignKey( + entity = OwnedBeacon.class, + parentColumns = {"id"}, + childColumns = {"beacon_id"}, + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + ) + } +) +public class LastBleSighting { + @PrimaryKey + @NonNull + @ColumnInfo(name = "beacon_id") + public String beaconId; + + /** When the advertisement was heard, so whatever it carried can be shown with its age. */ + @ColumnInfo(name = "heard_at") + public long heardAt; + + /** + * The battery level it reported, as the name of a + * {@code FindMyAdvertisement.BatteryLevel}. + * + *

Not its ordinal. An ordinal is a position in a source file, so reordering the enum - a + * change that looks harmless and compiles - would silently reinterpret every row already + * written on every user's phone. A name is only ever wrong if somebody renames a constant, + * which is a rename the compiler cannot hide either. + * + *

Read back through {@code BeaconRepository}, which treats an unrecognised name as no + * reading rather than guessing: a row written by a later version that knows a level this one + * does not must not be shown as some neighbouring level. + */ + @NonNull + @ColumnInfo(name = "battery_level") + public String batteryLevel; + + /** + * The whole status byte {@link #batteryLevel} was read out of. + * + *

Redundant on purpose, and cheap. The battery is two bits of it, decoded per a table that + * nobody outside Apple has confirmed in full - {@code LocationReportFields} is explicit about + * which parts of that byte are documented and which are inferred. Keeping the byte means a + * disputed reading can be re-derived from what was actually received, and that a bug report + * can quote the source rather than only this app's reading of it. The same reason the debug + * panel always shows the raw number beside the label. + */ + @ColumnInfo(name = "status_byte") + public int statusByte; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LocationReport.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LocationReport.java index ab26741f..0596fa16 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LocationReport.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LocationReport.java @@ -69,4 +69,34 @@ public class LocationReport { @ColumnInfo(name = "last_update") public long lastUpdate; + + /** + * Where this report came from: {@code apple} or {@code local}. + * + *

An Apple report and a locally heard sighting are the same shape and not the same + * claim. An Apple row says some stranger's iPhone overheard the tag and reported a + * position it worked out for itself, typically to within a hundred metres or worse. A local + * row says this phone heard the tag directly, which puts it inside Bluetooth range - tens of + * metres - and records the phone's own position as the tag's. + * + *

Both belong in this table, because everything that draws a tag reads from here: the map + * marker, the "last updated" line, the navigate button and the history. A separate table + * would mean teaching all of them about a second source. + * + *

The column exists because the history is exported. Without it the CSV hands + * somebody a file where their own phone's positions sit unlabelled among Apple's, and + * nothing in the file says which is which. + * + *

Defaults to {@code apple}, which is correct for every row written before this existed: + * they all came from the network. + */ + @NonNull + @ColumnInfo(name = "provenance", defaultValue = PROVENANCE_APPLE) + public String provenance; + + /** Decrypted from Apple's Find My network. */ + public static final String PROVENANCE_APPLE = "apple"; + + /** Heard by this phone's own radio, positioned from this phone's own location. */ + public static final String PROVENANCE_LOCAL = "local"; } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/UserBeaconOptions.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/UserBeaconOptions.java index 5b36f0bb..d779acc3 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/UserBeaconOptions.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/UserBeaconOptions.java @@ -54,4 +54,24 @@ public class UserBeaconOptions { */ @ColumnInfo(name = "ui_order") public Integer uiOrder; + + /** + * Whether to warn when this tag is left behind, or null if the user has not decided. + * + *

Null means no. Most tags a person owns are routinely put down on purpose: the + * spare key in a drawer, the tag in a car, the one on a bag that lives in the hall. An alert + * that fires for all of them until each is switched off individually is a stream of false + * alarms, and the thing people switch off after the second one is the whole feature. So a + * tag has to be asked for by name, and the switch is the asking. + * + *

Per tag because the answer genuinely differs per tag. Keys and a wallet are worth a + * noise; a tag that lives in a car, or on something that is meant to stay behind, would + * alert every time its owner walks into the house. One switch for all of them would be + * turned off by the first tag that cried wolf, taking the useful ones with it. + * + *

Here rather than on {@code OwnedBeacons} for the reason this whole table exists: it is + * the user's decision, and an account refresh must not touch it. + */ + @ColumnInfo(name = "alert_on_separation") + public Boolean alertOnSeparation; } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java new file mode 100644 index 00000000..c92b22dc --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java @@ -0,0 +1,135 @@ +package dev.wander.android.opentagviewer.python; + +import androidx.annotation.Nullable; + +import java.util.Map; + +/** + * The BLE MAC address(es) an accessory might currently be advertising. + * + *

Behind an interface for the same reason as {@link HardwareDescriber}: the real one is + * Chaquopy, starts an interpreter and runs an EC point derivation, so a screen that called it + * directly could not be launched in a test without all of that working. + * + *

Used to recognise an owned accessory's own advertisement in a BLE scan - see the {@code ble} + * package - so it can be triggered directly (playing a sound) without going through Apple's Find + * My network, the same thing Find My itself does when a tag is close enough to reach over + * Bluetooth. + */ +public interface AccessoryMacResolver { + + /** + * @param accessoryJson the persisted {@code OwnedBeacon.accessoryJson} for this beacon. + * @return each candidate MAC address mapped to the key index it came from, or an + * empty map if none could be resolved. An unreadable or null {@code accessoryJson} reports + * empty rather than throwing, since a beacon whose accessory JSON has not yet been + * backfilled (see {@code OwnedBeacon.accessoryJson}) is a real state the caller must be able + * to show, not a bug in this call. + * + *

The index is what {@link #recordSeen} needs, and the reason this is a map rather than + * the list it was: the search runs with a twelve-hour margin either side of the believed + * alignment, and feeding a match back is what collapses the next call to a single index. + */ + Map currentMacAddresses(String accessoryJson); + + /** + * Record that this accessory was seen advertising as {@code mac}, and return its new + * serialized state for the caller to persist. + * + *

A BLE sighting is an observation of the same kind as a decrypted location report, and + * can be worth the same thing: it can pin the rolling-key alignment, so the next scan + * derives three keys instead of a twelve-hour range. Persisting it is the caller's job - see + * {@code BeaconRepository#recordAccessorySighting}. + * + *

The address decides, the index is only a hint. An index is trustworthy only + * when {@code mac} came from a primary key - a secondary key's index is a lower bound, not + * the true one - and this side of the bridge cannot tell the two apart. So Python re-derives + * the keys itself and reads the type there; {@code hintIndex} only says where to look + * first, and a wrong one costs nothing but the wide search that used to happen anyway. + * + *

The hint is what makes this affordable to call. Checking one index is three key + * derivations; searching the whole candidate window is around 1150, measured at 1.15s on + * desktop and several times that under Chaquopy. Called on the sighting cadence without it, + * the app sat at 135% CPU with two tags in range until Android killed it for not answering + * input. + * + * @param hintIndex the index {@code currentMacAddresses} paired {@code mac} with, or null + * when the caller does not know - the search then runs as it did before. + * + * @return the re-serialized accessory, or null if there was nothing worth recording - no + * match, a match that leaves alignment where it already is, or a failure. A secondary-key + * match does record, but only upward: it raises the alignment floor to a lower bound on the + * true index rather than setting the index itself, which only a primary match may do. Null is not worth failing a caller over: + * the sighting is an optimisation, and the sound either played or it did not regardless. + * + *

Defaulted to "records nothing" rather than a second required method, so a + * {@code currentMacAddresses}-only lambda - most of this interface's test doubles, which + * only ever care about the candidate set - keeps compiling. {@link ChaquopyAccessoryMacResolver} + * overrides it for real. + */ + /** + * The inclusive key index range worth scanning for this accessory right now, or null if it + * cannot be read. + * + *

Separate from {@link #currentMacAddresses} because it costs nothing: it says which part + * of the range matters without deriving a single address. A caller that keeps what it + * derived last time needs exactly this to work out what it is missing, and not re-deriving + * what it already holds is the whole reason for keeping it. + * + *

Defaulted to null so a test double that only cares about candidate addresses keeps + * compiling, same as {@link #recordSeen}. + */ + @Nullable + default IndexRange candidateWindow(String accessoryJson) { + return null; + } + + /** + * The addresses this accessory can advertise across an inclusive key index range. + * + *

Takes the range rather than choosing one, so a caller widening its search downward can + * name the piece below whatever {@link #currentMacAddresses} would have picked. + * + *

The address set is stable; the index attached to it is not. A secondary key is + * reported at the first index the call's own range reaches, so the same address comes back + * against a different index depending on where the range started. Primary keys do not move. + * See {@code main.addressesBetween}. + */ + default Map addressesBetween(String accessoryJson, int lo, int hi) { + return Map.of(); + } + + /** An inclusive range of key indices. */ + final class IndexRange { + private final int lo; + private final int hi; + + public IndexRange(final int lo, final int hi) { + this.lo = lo; + this.hi = hi; + } + + public int getLo() { + return this.lo; + } + + public int getHi() { + return this.hi; + } + + /** How many indices this covers, which is what the derivation is charged by. */ + public int width() { + return this.hi < this.lo ? 0 : this.hi - this.lo + 1; + } + + @Override + public String toString() { + return this.lo + ".." + this.hi; + } + } + + default String recordSeen( + String accessoryJson, String mac, long seenAtUnixMs, Integer hintIndex) { + return null; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java index 6cfc11fb..89ed6e70 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java @@ -1,232 +1,286 @@ -package dev.wander.android.opentagviewer.python; - -import android.content.Context; -import android.location.Geocoder; - -import org.chromium.net.CronetEngine; - -import androidx.annotation.VisibleForTesting; - -import java.util.Locale; -import java.util.function.BiFunction; -import java.util.function.Function; -import java.util.function.Supplier; - -import dev.wander.android.opentagviewer.anisette.AnisetteSource; -import dev.wander.android.opentagviewer.anisette.LocalAnisette; -import dev.wander.android.opentagviewer.python.icloud.ICloudService; -import dev.wander.android.opentagviewer.python.icloud.PythonICloudService; -import dev.wander.android.opentagviewer.db.repo.model.UserSettings; -import dev.wander.android.opentagviewer.service.web.AnisetteServerTesterService; -import dev.wander.android.opentagviewer.util.android.AddressLookup; - -/** - * What the sign-in screen depends on, in one place a test can replace. - * - *

The screen builds everything it needs inside {@code onCreate}, which is the ordinary - * Android shape and fine right up until you want to launch it. Two of those things reach the - * network before a single view is drawn: signing in runs Python against Apple, and local - * Anisette downloads Apple's ADI libraries from their CDN. Neither can be arranged in a test, - * so the whole four-page flow - the part of the app with the most transitions and the least - * coverage - could only ever be checked by hand with a real account and a real phone. - * - *

A settable global rather than constructor injection because an activity is - * constructed by the framework, and this app has no DI container to teach otherwise. The - * alternative shapes all cost more than they are worth here: an Application subclass holding - * these is the same global with more indirection, and a whole framework is a large change to - * this codebase for one screen. Production never calls the setters; they are for tests, and - * {@link #reset()} in a teardown puts the real ones back. - */ -public final class AppDependencies { - - private AppDependencies() {} - - private static AppleAuthService authService = new PythonAppleAuthService(); - - /** - * How to build Anisette for a given settings object. A factory rather than an instance - * because the real one needs a Context and the current settings, and neither exists when - * this class is loaded. - */ - private static AnisetteFactory anisetteFactory = LocalAnisette::new; - - /** Builds the Anisette source for a screen, given where it is running and who is signed in. */ - public interface AnisetteFactory { - AnisetteSource create(Context context, UserSettings settings, boolean hasExistingSession); - } - - /** - * How to build the thing that asks an Anisette server whether it is alive. - * - *

Here for the same reason as the rest: the sign-in screen tests a server before it - * will let anybody past, so a test of the fall-back path would otherwise depend on a - * stranger's machine being up. - */ - private static Function serverTesterFactory = - AnisetteServerTesterService::new; - - /** - * Names an accessory from its plist, through the shared Python heuristic. - * - *

Here for the same reason as the rest: the real one starts Chaquopy and imports a - * package, so a screen that used it directly could not be launched in a test. It also makes - * "an accessory nothing recognises" renderable on demand, rather than needing such a tag. - */ - private static HardwareDescriber hardwareDescriber = new ChaquopyHardwareDescriber(); - - /** - * Strips personal identifiers out of a log before it is offered to anybody. - * - *

Here for the usual reason and one sharper one: the screen that offers a log is the error - * page, which exists because something already broke. A test of it has to be able to - * produce a working redactor and one that cannot run, and the second is the case that decides - * whether an unredacted log can escape. - */ - private static LogRedactor logRedactor = new ChaquopyLogRedactor(); - - /** - * Builds an export bundle's files. - * - *

Here because the failure path is the one that matters and cannot be reached on - * demand. An export that throws leaves somebody with no file and no explanation, having - * just decided to share the keys to their tags - and producing that state for real means - * breaking the interpreter. A fake produces it in a line. - */ - private static BundleBuilder bundleBuilder = new ChaquopyBundleBuilder(); - - /** - * Turns coordinates into something a person recognises. - * - *

Here because a screen with no geocoder does not look broken. The card falls back - * to the raw latitude and longitude, which is a perfectly reasonable thing for it to show - * when an address genuinely cannot be found - so a geocoder that answers nothing at all is - * indistinguishable, on screen and in a screenshot, from one that answered honestly. - * - *

Which is the state every instrumented run is in: the {@code aosp-atd} image carries no - * geocoding backend, so {@code getFromLocation} returns an empty list for every point on - * earth and the whole path - the rounding, the cache, the fallback - is exercised by - * nothing. A test that wants to assert a place name has to be able to supply one. - * - *

A factory rather than an instance, because a {@link Geocoder} is built per screen from - * that screen's context and the current locale. - */ - private static BiFunction geocoderFactory = - (context, locale) -> AddressLookup.through(new Geocoder(context, locale)); - - public static AddressLookup geocoder(final Context context, final Locale locale) { - return geocoderFactory.apply(context, locale); - } - - @VisibleForTesting - public static void replaceGeocoder( - final BiFunction replacement) { - geocoderFactory = replacement; - } - - /** - * Opens a conversation with iCloud on the signed-in account. - * - *

A supplier rather than an instance because a session is not reusable: it holds a - * keychain session and a CloudKit client, both with sockets, and it is closed when the - * screen that opened it goes away. - * - *

Here for the usual reason, more sharply than most. Every failure this flow has to - * handle - an account with nothing to recover from, a service having a bad day, a rejected - * passcode - needs an Apple account in a state nobody can arrange on demand, and the ones - * that matter most are the ones a real account will never be in. - */ - private static Supplier icloudFactory = AppDependencies::openRealICloud; - - private static ICloudService openRealICloud() { - final PythonAppleService signedIn = PythonAppleService.getInstance(); - if (signedIn == null || signedIn.getAccount() == null) { - return null; - } - - return PythonICloudService.openFor(signedIn.getAccount()); - } - - /** - * A new iCloud session, or null when there is no usable signed-in account. - * - *

Null is not a crash: the caller reports it as needing a sign-in, which is the same - * recovery as a session that has expired. - */ - public static ICloudService icloud() { - return icloudFactory.get(); - } - - @VisibleForTesting - public static void replaceICloud(final Supplier replacement) { - icloudFactory = replacement; - } - - public static AppleAuthService authService() { - return authService; - } - - public static HardwareDescriber hardwareDescriber() { - return hardwareDescriber; - } - - public static LogRedactor logRedactor() { - return logRedactor; - } - - public static BundleBuilder bundleBuilder() { - return bundleBuilder; - } - - public static AnisetteServerTesterService serverTester(final CronetEngine engine) { - return serverTesterFactory.apply(engine); - } - - @VisibleForTesting - public static void replaceServerTester(final AnisetteServerTesterService replacement) { - serverTesterFactory = engine -> replacement; - } - - public static AnisetteSource anisette( - final Context context, final UserSettings settings, final boolean hasExistingSession) { - return anisetteFactory.create(context, settings, hasExistingSession); - } - - @VisibleForTesting - public static void replaceAuthService(final AppleAuthService replacement) { - authService = replacement; - } - - @VisibleForTesting - public static void replaceHardwareDescriber(final HardwareDescriber replacement) { - hardwareDescriber = replacement; - } - - @VisibleForTesting - public static void replaceLogRedactor(final LogRedactor replacement) { - logRedactor = replacement; - } - - @VisibleForTesting - public static void replaceBundleBuilder(final BundleBuilder replacement) { - bundleBuilder = replacement; - } - - @VisibleForTesting - public static void replaceAnisette(final Function replacement) { - anisetteFactory = (context, settings, hasSession) -> replacement.apply(settings); - } - - /** Put the real ones back. Call from a teardown, or the next test inherits a fake. */ - @VisibleForTesting - public static void reset() { - authService = new PythonAppleAuthService(); - anisetteFactory = LocalAnisette::new; - serverTesterFactory = AnisetteServerTesterService::new; - hardwareDescriber = new ChaquopyHardwareDescriber(); - logRedactor = new ChaquopyLogRedactor(); - bundleBuilder = new ChaquopyBundleBuilder(); - icloudFactory = AppDependencies::openRealICloud; - geocoderFactory = (context, locale) -> - AddressLookup.through(new Geocoder(context, locale)); - } -} +package dev.wander.android.opentagviewer.python; + +import android.content.Context; +import android.location.Geocoder; + +import org.chromium.net.CronetEngine; + +import androidx.annotation.VisibleForTesting; + +import java.util.Locale; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; + +import dev.wander.android.opentagviewer.anisette.AnisetteSource; +import dev.wander.android.opentagviewer.anisette.LocalAnisette; +import dev.wander.android.opentagviewer.ble.AccessorySoundTrigger; +import dev.wander.android.opentagviewer.ble.BleAccessorySoundTrigger; +import dev.wander.android.opentagviewer.python.icloud.ICloudService; +import dev.wander.android.opentagviewer.python.icloud.PythonICloudService; +import dev.wander.android.opentagviewer.db.repo.model.UserSettings; +import dev.wander.android.opentagviewer.service.web.AnisetteServerTesterService; +import dev.wander.android.opentagviewer.util.android.AddressLookup; + +/** + * What the sign-in screen depends on, in one place a test can replace. + * + *

The screen builds everything it needs inside {@code onCreate}, which is the ordinary + * Android shape and fine right up until you want to launch it. Two of those things reach the + * network before a single view is drawn: signing in runs Python against Apple, and local + * Anisette downloads Apple's ADI libraries from their CDN. Neither can be arranged in a test, + * so the whole four-page flow - the part of the app with the most transitions and the least + * coverage - could only ever be checked by hand with a real account and a real phone. + * + *

A settable global rather than constructor injection because an activity is + * constructed by the framework, and this app has no DI container to teach otherwise. The + * alternative shapes all cost more than they are worth here: an Application subclass holding + * these is the same global with more indirection, and a whole framework is a large change to + * this codebase for one screen. Production never calls the setters; they are for tests, and + * {@link #reset()} in a teardown puts the real ones back. + */ +public final class AppDependencies { + + private AppDependencies() {} + + private static AppleAuthService authService = new PythonAppleAuthService(); + + /** + * How to build Anisette for a given settings object. A factory rather than an instance + * because the real one needs a Context and the current settings, and neither exists when + * this class is loaded. + */ + private static AnisetteFactory anisetteFactory = LocalAnisette::new; + + /** Builds the Anisette source for a screen, given where it is running and who is signed in. */ + public interface AnisetteFactory { + AnisetteSource create(Context context, UserSettings settings, boolean hasExistingSession); + } + + /** + * How to build the thing that asks an Anisette server whether it is alive. + * + *

Here for the same reason as the rest: the sign-in screen tests a server before it + * will let anybody past, so a test of the fall-back path would otherwise depend on a + * stranger's machine being up. + */ + private static Function serverTesterFactory = + AnisetteServerTesterService::new; + + /** + * Names an accessory from its plist, through the shared Python heuristic. + * + *

Here for the same reason as the rest: the real one starts Chaquopy and imports a + * package, so a screen that used it directly could not be launched in a test. It also makes + * "an accessory nothing recognises" renderable on demand, rather than needing such a tag. + */ + private static HardwareDescriber hardwareDescriber = new ChaquopyHardwareDescriber(); + + /** + * Resolves an accessory's current BLE MAC address candidate(s), through the pinned + * FindMy.py fork's rolling-key derivation. + * + *

Here for the same reason as {@link #hardwareDescriber}: the real one starts Chaquopy, + * so a screen or a test of {@link #accessorySoundTrigger} could not otherwise run without it. + */ + private static AccessoryMacResolver accessoryMacResolver = new ChaquopyAccessoryMacResolver(); + + /** + * Plays an owned accessory's sound directly over Bluetooth - see + * {@code dev.wander.android.opentagviewer.ble.AccessorySoundTrigger}. + * + *

Built from {@link #accessoryMacResolver} rather than constructing its own, so replacing + * one in a test replaces what the other depends on too. + * + *

Null until asked for, which is what makes the sentence above true. Built eagerly + * here, it captured whichever resolver existed at class-init - a {@code final} field inside + * it - so {@link #replaceAccessoryMacResolver} swapped this class's field and left the + * trigger holding the real Chaquopy one. A test that stubbed only the resolver would then + * start CPython, which is the single thing that seam exists to avoid, and it would do it + * without failing: Chaquopy works on a device, so the test passes slowly rather than + * loudly. + */ + private static AccessorySoundTrigger accessorySoundTrigger = null; + + /** + * Strips personal identifiers out of a log before it is offered to anybody. + * + *

Here for the usual reason and one sharper one: the screen that offers a log is the error + * page, which exists because something already broke. A test of it has to be able to + * produce a working redactor and one that cannot run, and the second is the case that decides + * whether an unredacted log can escape. + */ + private static LogRedactor logRedactor = new ChaquopyLogRedactor(); + + /** + * Builds an export bundle's files. + * + *

Here because the failure path is the one that matters and cannot be reached on + * demand. An export that throws leaves somebody with no file and no explanation, having + * just decided to share the keys to their tags - and producing that state for real means + * breaking the interpreter. A fake produces it in a line. + */ + private static BundleBuilder bundleBuilder = new ChaquopyBundleBuilder(); + + /** + * Turns coordinates into something a person recognises. + * + *

Here because a screen with no geocoder does not look broken. The card falls back + * to the raw latitude and longitude, which is a perfectly reasonable thing for it to show + * when an address genuinely cannot be found - so a geocoder that answers nothing at all is + * indistinguishable, on screen and in a screenshot, from one that answered honestly. + * + *

Which is the state every instrumented run is in: the {@code aosp-atd} image carries no + * geocoding backend, so {@code getFromLocation} returns an empty list for every point on + * earth and the whole path - the rounding, the cache, the fallback - is exercised by + * nothing. A test that wants to assert a place name has to be able to supply one. + * + *

A factory rather than an instance, because a {@link Geocoder} is built per screen from + * that screen's context and the current locale. + */ + private static BiFunction geocoderFactory = + (context, locale) -> AddressLookup.through(new Geocoder(context, locale)); + + public static AddressLookup geocoder(final Context context, final Locale locale) { + return geocoderFactory.apply(context, locale); + } + + @VisibleForTesting + public static void replaceGeocoder( + final BiFunction replacement) { + geocoderFactory = replacement; + } + + /** + * Opens a conversation with iCloud on the signed-in account. + * + *

A supplier rather than an instance because a session is not reusable: it holds a + * keychain session and a CloudKit client, both with sockets, and it is closed when the + * screen that opened it goes away. + * + *

Here for the usual reason, more sharply than most. Every failure this flow has to + * handle - an account with nothing to recover from, a service having a bad day, a rejected + * passcode - needs an Apple account in a state nobody can arrange on demand, and the ones + * that matter most are the ones a real account will never be in. + */ + private static Supplier icloudFactory = AppDependencies::openRealICloud; + + private static ICloudService openRealICloud() { + final PythonAppleService signedIn = PythonAppleService.getInstance(); + if (signedIn == null || signedIn.getAccount() == null) { + return null; + } + + return PythonICloudService.openFor(signedIn.getAccount()); + } + + /** + * A new iCloud session, or null when there is no usable signed-in account. + * + *

Null is not a crash: the caller reports it as needing a sign-in, which is the same + * recovery as a session that has expired. + */ + public static ICloudService icloud() { + return icloudFactory.get(); + } + + @VisibleForTesting + public static void replaceICloud(final Supplier replacement) { + icloudFactory = replacement; + } + + public static AppleAuthService authService() { + return authService; + } + + public static HardwareDescriber hardwareDescriber() { + return hardwareDescriber; + } + + public static AccessoryMacResolver accessoryMacResolver() { + return accessoryMacResolver; + } + + public static AccessorySoundTrigger accessorySoundTrigger() { + if (accessorySoundTrigger == null) { + accessorySoundTrigger = BleAccessorySoundTrigger.forRealBluetooth(accessoryMacResolver); + } + return accessorySoundTrigger; + } + + public static LogRedactor logRedactor() { + return logRedactor; + } + + public static BundleBuilder bundleBuilder() { + return bundleBuilder; + } + + public static AnisetteServerTesterService serverTester(final CronetEngine engine) { + return serverTesterFactory.apply(engine); + } + + @VisibleForTesting + public static void replaceServerTester(final AnisetteServerTesterService replacement) { + serverTesterFactory = engine -> replacement; + } + + public static AnisetteSource anisette( + final Context context, final UserSettings settings, final boolean hasExistingSession) { + return anisetteFactory.create(context, settings, hasExistingSession); + } + + @VisibleForTesting + public static void replaceAuthService(final AppleAuthService replacement) { + authService = replacement; + } + + @VisibleForTesting + public static void replaceHardwareDescriber(final HardwareDescriber replacement) { + hardwareDescriber = replacement; + } + + @VisibleForTesting + public static void replaceAccessoryMacResolver(final AccessoryMacResolver replacement) { + accessoryMacResolver = replacement; + // Dropped rather than rebuilt, so an explicit replaceAccessorySoundTrigger made after + // this one still wins. It is rebuilt from the new resolver on the next call. + accessorySoundTrigger = null; + } + + @VisibleForTesting + public static void replaceAccessorySoundTrigger(final AccessorySoundTrigger replacement) { + accessorySoundTrigger = replacement; + } + + @VisibleForTesting + public static void replaceLogRedactor(final LogRedactor replacement) { + logRedactor = replacement; + } + + @VisibleForTesting + public static void replaceBundleBuilder(final BundleBuilder replacement) { + bundleBuilder = replacement; + } + + @VisibleForTesting + public static void replaceAnisette(final Function replacement) { + anisetteFactory = (context, settings, hasSession) -> replacement.apply(settings); + } + + /** Put the real ones back. Call from a teardown, or the next test inherits a fake. */ + @VisibleForTesting + public static void reset() { + authService = new PythonAppleAuthService(); + anisetteFactory = LocalAnisette::new; + serverTesterFactory = AnisetteServerTesterService::new; + hardwareDescriber = new ChaquopyHardwareDescriber(); + accessoryMacResolver = new ChaquopyAccessoryMacResolver(); + accessorySoundTrigger = null; + logRedactor = new ChaquopyLogRedactor(); + bundleBuilder = new ChaquopyBundleBuilder(); + icloudFactory = AppDependencies::openRealICloud; + geocoderFactory = (context, locale) -> + AddressLookup.through(new Geocoder(context, locale)); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java new file mode 100644 index 00000000..93a06832 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java @@ -0,0 +1,139 @@ +package dev.wander.android.opentagviewer.python; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.chaquo.python.PyObject; +import com.chaquo.python.Python; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * The real resolver: calls {@code main.py:currentMacAddresses}, which delegates to + * {@code RollingKeyPairSource.current_mac_addresses} in FindMy.py. + * + *

The Python runtime is resolved lazily per call rather than held as a field, so constructing + * this does not require Chaquopy to have started - same reasoning as + * {@link ChaquopyHardwareDescriber}. + * + *

Blocking, and starts an interpreter. Never call this on the main thread; the {@code + * ble} package that uses it does so on an Rx scheduler. + */ +public class ChaquopyAccessoryMacResolver implements AccessoryMacResolver { + private static final String TAG = ChaquopyAccessoryMacResolver.class.getSimpleName(); + private static final String MODULE_MAIN = "main"; + + @Override + public Map currentMacAddresses(final String accessoryJson) { + if (accessoryJson == null || accessoryJson.isEmpty()) { + // Not yet backfilled from the legacy plist - see OwnedBeacon.accessoryJson. A real + // state, not a failure, so this reports it the same way Python does: nothing found. + return Collections.emptyMap(); + } + + try { + final var module = Python.getInstance().getModule(MODULE_MAIN); + final PyObject returned = module.callAttr("currentMacAddresses", accessoryJson); + + if (returned == null) { + Log.w(TAG, "currentMacAddresses returned None (check python logs for details)"); + return Collections.emptyMap(); + } + + // Crossed once, here, and matched in Java from then on. A call per advertisement + // would pay the derivation and the marshalling for every device in range. + final Map candidates = new HashMap<>(); + for (final Map.Entry entry : returned.asMap().entrySet()) { + candidates.put(entry.getKey().toString(), entry.getValue().toInt()); + } + return candidates; + } catch (final Exception e) { + // Either Python has not started, or the accessory JSON could not be read. Neither + // is worth failing the caller over: it reads as "nothing to match against yet". + Log.w(TAG, "currentMacAddresses failed", e); + return Collections.emptyMap(); + } + } + + @Override + @Nullable + public IndexRange candidateWindow(final String accessoryJson) { + if (accessoryJson == null || accessoryJson.isEmpty()) { + return null; + } + + try { + final var module = Python.getInstance().getModule(MODULE_MAIN); + final PyObject returned = module.callAttr("candidateWindow", accessoryJson); + + if (returned == null) { + return null; + } + + final Map window = returned.asMap(); + return new IndexRange( + window.get(PyObject.fromJava("lo")).toInt(), + window.get(PyObject.fromJava("hi")).toInt()); + } catch (final Exception e) { + Log.w(TAG, "candidateWindow failed", e); + return null; + } + } + + @Override + public Map addressesBetween( + final String accessoryJson, final int lo, final int hi) { + if (accessoryJson == null || accessoryJson.isEmpty() || hi < lo) { + return Collections.emptyMap(); + } + + try { + final var module = Python.getInstance().getModule(MODULE_MAIN); + final PyObject returned = + module.callAttr("addressesBetween", accessoryJson, lo, hi); + + if (returned == null) { + Log.w(TAG, "addressesBetween returned None (check python logs for details)"); + return Collections.emptyMap(); + } + + final Map derived = new HashMap<>(); + for (final Map.Entry entry : returned.asMap().entrySet()) { + final int index = entry.getValue().toInt(); + // Python reports a secondary key's index as -1, because the number it would + // otherwise give is where the search began rather than where the tag is. Carried + // on as no hint at all: a wrong hint costs a check that fails and then the wide + // search anyway, which is strictly worse than not guessing. + derived.put(entry.getKey().toString(), index < 0 ? null : index); + } + return derived; + } catch (final Exception e) { + Log.w(TAG, "addressesBetween failed", e); + return Collections.emptyMap(); + } + } + + @Override + public String recordSeen( + final String accessoryJson, final String mac, final long seenAtUnixMs, + final Integer hintIndex) { + if (accessoryJson == null || accessoryJson.isEmpty() || mac == null) { + return null; + } + + try { + final var module = Python.getInstance().getModule(MODULE_MAIN); + final PyObject returned = module.callAttr( + "recordAccessorySeen", accessoryJson, mac, seenAtUnixMs, hintIndex); + + return returned == null ? null : returned.toString(); + } catch (final Exception e) { + // Losing a sighting costs the next scan a wider search, nothing else. + Log.w(TAG, "recordAccessorySeen failed", e); + return null; + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonDiagnostics.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonDiagnostics.java new file mode 100644 index 00000000..d6c15490 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonDiagnostics.java @@ -0,0 +1,68 @@ +package dev.wander.android.opentagviewer.python; + +import android.content.Context; +import android.util.Log; + +import com.chaquo.python.Python; + +import java.io.File; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * Gives the Python side somewhere to write a measurement that outlives a logcat buffer. + * + *

Why a file at all. The alignment drift the fetch path reports is only worth + * something as a series over weeks: one reading says nothing, and the question it answers - does + * a tag that is merely out of contact stay where the extrapolation says it is - is what decides + * how wide a search has to be. Logcat on a busy phone holds minutes, so relying on it would mean + * asking somebody to leave a phone plugged into a computer for a fortnight. + * + *

The app's external files directory, because that one can be pulled off a device with adb + * without root and without a debuggable build - which a release build is not. Nothing private + * goes in it: an index and a difference of two indices. + * + *

Attached lazily, and never on its own account. Reaching Python starts an interpreter, + * which was measured at eleven to twelve seconds on a device. Doing that so a diagnostic can + * introduce itself would be a bad trade, so this only ever runs from somewhere that is about to + * start Python anyway, on a background thread, once per process. + */ +public final class PythonDiagnostics { + private static final String TAG = PythonDiagnostics.class.getSimpleName(); + private static final String MODULE_MAIN = "main"; + + private static final AtomicBoolean attached = new AtomicBoolean(false); + + private PythonDiagnostics() {} + + /** + * Tells Python where to append diagnostics, the first time it is called in this process. + * + *

Silent on failure. Losing a diagnostic is not worth telling anybody about, and this + * must never be the reason something else did not happen. + */ + public static void attach(final Context context) { + if (!attached.compareAndSet(false, true)) { + return; + } + + final File directory = context.getApplicationContext().getExternalFilesDir(null); + if (directory == null) { + // No external storage mounted. Python keeps printing to logcat, which is what it did + // before there was a file at all. + Log.d(TAG, "No external files directory; diagnostics stay in logcat"); + return; + } + + Schedulers.io().scheduleDirect(() -> { + try { + Python.getInstance().getModule(MODULE_MAIN) + .callAttr("setDiagnosticsPath", directory.getAbsolutePath()); + Log.i(TAG, "Diagnostics will be appended to " + directory + "/diagnostics.log"); + } catch (final Exception couldNotAttach) { + Log.d(TAG, "Could not point Python at a diagnostics file", couldNotAttach); + } + }); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/service/BootReceiver.java b/app/src/main/java/dev/wander/android/opentagviewer/service/BootReceiver.java new file mode 100644 index 00000000..80907fb9 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/service/BootReceiver.java @@ -0,0 +1,59 @@ +package dev.wander.android.opentagviewer.service; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore; +import dev.wander.android.opentagviewer.db.repo.UserSettingsRepository; +import dev.wander.android.opentagviewer.db.repo.model.UserSettings; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * Brings the background scan back after the phone restarts. + * + *

Without this the setting quietly stops meaning anything. A service does not survive + * a reboot, and nothing else starts this app on its own - so somebody who turned background + * scanning on would get it until their next restart, and then silence until they happened to + * open the app again. That is the failure mode this whole feature exists to avoid: it is meant + * to be listening precisely when nobody is looking at the app. + * + *

Starting a foreground service from here is allowed, which is not true of most + * background starts on Android 12 and later: {@code BOOT_COMPLETED} is one of the named + * exemptions. + * + *

Reads the setting first and starts nothing for anybody who has not asked. The setting is + * the state; the service is only its consequence. + */ +public class BootReceiver extends BroadcastReceiver { + private static final String TAG = BootReceiver.class.getSimpleName(); + + @Override + public void onReceive(final Context context, final Intent intent) { + if (!Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) { + return; + } + + final Context appContext = context.getApplicationContext(); + + // A receiver's onReceive runs on the main thread and is expected to return promptly, + // and the setting lives in a DataStore. goAsync would keep the process alive for the + // read; starting the service is itself the thing that keeps it alive, so a plain + // scheduler hop is enough here. + Schedulers.io().scheduleDirect(() -> { + try { + final UserSettings settings = + new UserSettingsRepository(UserSettingsDataStore.getInstance(appContext)) + .getUserSettings(); + + if (settings.shouldScanInBackground()) { + Log.i(TAG, "Background scanning is on; starting the service after boot"); + NearbyScanService.start(appContext); + } + } catch (final Exception e) { + Log.w(TAG, "Could not read whether to scan in the background after boot", e); + } + }); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/service/LeftBehindAlarm.java b/app/src/main/java/dev/wander/android/opentagviewer/service/LeftBehindAlarm.java new file mode 100644 index 00000000..ec7e12c5 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/service/LeftBehindAlarm.java @@ -0,0 +1,145 @@ +package dev.wander.android.opentagviewer.service; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.Nullable; + +/** + * Plays the left-behind alarm, on repeat, until somebody deals with it. + * + *

Why the sound is not on the notification channel. A channel's sound is fixed at the + * moment it is created and cannot be changed afterwards - setting it again in code does nothing + * for anyone who already has the channel. A sound the user picks has to be changeable, so the + * channel is left silent and the audio is played here instead. That buys the repeat as well: a + * channel plays its sound once, which is a chime, and a chime from a pocket during a walk is + * exactly the thing that gets missed. + * + *

Alarm usage, deliberately. Notification audio plays at notification volume, which on + * a phone that has been quietened is nothing at all. This is the one notification in the app + * allowed to interrupt, so it goes out the way an alarm clock does and stays audible with the + * ringer down. + * + *

It stops itself. A loop with only one way out is a loop that eventually runs in + * somebody's pocket for an hour, so {@link #MAX_DURATION_MS} ends it regardless of whether the + * notification was ever touched. Being told twice is a nuisance; a siren nobody can find is a + * reason to uninstall. + */ +public final class LeftBehindAlarm { + private static final String TAG = LeftBehindAlarm.class.getSimpleName(); + + /** + * How long the alarm repeats before giving up on being answered. + * + *

Long enough to be heard through a coat and walked back for, short enough that a phone + * left on a table does not make a scene. The notification stays either way - the sound is + * what is time-limited, not the message. + */ + static final long MAX_DURATION_MS = 60_000L; + + private final Context context; + private final Handler handler = new Handler(Looper.getMainLooper()); + + @Nullable + private MediaPlayer player; + + public LeftBehindAlarm(final Context context) { + this.context = context.getApplicationContext(); + } + + /** + * Starts the alarm, replacing one already sounding. + * + *

Replacing rather than layering: two tags left behind at once is one situation, and two + * alarm sounds over each other is just noise. Each still gets its own notification. + * + * @param soundUri what the user picked, or null/empty for the system's default alarm. + */ + public void start(@Nullable final String soundUri) { + this.stop(); + + final Uri sound = resolve(soundUri); + if (sound == null) { + Log.w(TAG, "No alarm sound available; the notification will be silent"); + return; + } + + try { + final MediaPlayer started = new MediaPlayer(); + started.setAudioAttributes(new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build()); + started.setDataSource(this.context, sound); + started.setLooping(true); + started.prepare(); + started.start(); + + this.player = started; + this.handler.postDelayed(this::stop, MAX_DURATION_MS); + + Log.i(TAG, "Left-behind alarm sounding"); + } catch (final Exception couldNotPlay) { + // A sound that has been deleted, a volume that is not mounted, an audio focus the + // system refused. None of it is worth failing the alert over: the notification is + // already posted and is the part that carries the information. + Log.w(TAG, "Could not play the left-behind alarm", couldNotPlay); + this.stop(); + } + } + + /** Silences the alarm. Safe to call when nothing is playing. */ + public void stop() { + this.handler.removeCallbacksAndMessages(null); + + final MediaPlayer sounding = this.player; + this.player = null; + + if (sounding == null) { + return; + } + + try { + if (sounding.isPlaying()) { + sounding.stop(); + } + } catch (final IllegalStateException alreadyGone) { + Log.d(TAG, "Alarm player was already finished", alreadyGone); + } finally { + sounding.release(); + } + } + + /** + * The user's choice, or the system default alarm when there is none or it cannot be read. + * + *

Falls back twice: an alarm sound the device does not have is answered with the + * notification sound rather than with silence, because this is the one alert where being + * quiet is the failure. + */ + @Nullable + private static Uri resolve(@Nullable final String soundUri) { + if (!TextUtils.isEmpty(soundUri)) { + return Uri.parse(soundUri); + } + + final Uri alarm = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM); + return alarm != null + ? alarm + : RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); + } + + /** Whether the phone's alarm stream is turned all the way down. */ + public boolean isAlarmStreamSilent() { + final AudioManager audio = this.context.getSystemService(AudioManager.class); + return audio != null && audio.getStreamVolume(AudioManager.STREAM_ALARM) == 0; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java b/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java new file mode 100644 index 00000000..c156cd38 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java @@ -0,0 +1,792 @@ +package dev.wander.android.opentagviewer.service; + +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.media.AudioAttributes; +import android.media.RingtoneManager; +import android.os.Build; +import android.os.IBinder; +import android.util.Log; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import java.util.Map; +import java.util.Set; + +import dev.wander.android.opentagviewer.MapsActivity; +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.AccessorySightingPersister; +import dev.wander.android.opentagviewer.ble.BlePermissions; +import dev.wander.android.opentagviewer.ble.NearbyTagWatcher; +import dev.wander.android.opentagviewer.db.repo.BeaconRepository; +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.util.android.CachedPhoneLocation; +import dev.wander.android.opentagviewer.util.android.FusedPhoneLocation; +import dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore; +import dev.wander.android.opentagviewer.db.repo.UserSettingsRepository; +import dev.wander.android.opentagviewer.db.repo.model.UserSettings; +import io.reactivex.rxjava3.schedulers.Schedulers; +import android.text.format.DateUtils; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import dev.wander.android.opentagviewer.util.LeftBehind; +import dev.wander.android.opentagviewer.util.LocalFixWorthKeeping; +import dev.wander.android.opentagviewer.util.android.PhoneLocation; +import io.reactivex.rxjava3.core.Observable; +import dev.wander.android.opentagviewer.ble.NearbyAccessoryScanner; +import java.util.HashMap; +import java.util.List; +import dev.wander.android.opentagviewer.data.model.BeaconInformation; +import dev.wander.android.opentagviewer.db.repo.model.BeaconData; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.util.parse.BeaconDataParser; +import io.reactivex.rxjava3.disposables.Disposable; + +/** + * Keeps listening for the owner's tags while the app is closed. + * + *

Opt-in, and that is not caution for its own sake. Everything else in this app + * listens only while a screen is open, which makes it a display feature. This one records: it + * runs continuously, writes down where tags were heard, and shows a permanent notification for + * as long as it does. The people who install an app to avoid Apple's tracking network have + * specific reasons to decide that for themselves rather than receive it in an update. + * + *

It is also what makes the local position history worth having. The question a + * history answers is "where did I leave it", and the app is shut at exactly that moment. Without + * this, the history records where somebody was while they had the app open, which is mostly at + * home with the tag in their pocket. + * + *

A foreground service because Android gives no other way. A background scan without + * one is throttled to the point of uselessness, and from Android 14 the service must declare + * what it is for - {@code location}, since it reads the phone's position to attribute a sighting. + * The permanent notification is the price of that, and it is honest: something is listening. + * + *

Same {@link NearbyTagWatcher} and the same {@code SCAN_MODE_BALANCED} the screens use. + * Low power was the obvious choice for a service that runs all day - a tenth of the radio time + * against a quarter - but it produced gaps of over a minute while a tag sat in a pocket, and a + * gap is what the left-behind rule has to see through. Fewer gaps also means fewer verification + * bursts at full power, so the cheaper mode is not obviously the cheaper answer. + * + *

Which of the two actually costs less has not been measured yet, and the duty cycles + * alone do not settle it. Worth making the user's choice once there is a number to put beside + * it. + */ +public class NearbyScanService extends Service { + private static final String TAG = NearbyScanService.class.getSimpleName(); + + private static final String CHANNEL_ID = "nearby_scan"; + private static final int NOTIFICATION_ID = 4711; + + /** + * Sent when the user swipes the notification away. + * + *

Since Android 14 that gesture is available even on a foreground service, and it + * removes only the notification. The service keeps scanning, invisibly, which is exactly + * the state a permanent notification exists to prevent. So the swipe is read as what it + * plainly means - stop doing this - and turns the setting off too, leaving the switch in + * Settings agreeing with reality. + */ + private static final String ACTION_DISMISSED = "dev.wander.opentagviewer.SCAN_DISMISSED"; + + /** + * Swiping the left-behind alert away, which silences the sound and nothing else. + * + *

Deliberately not {@link #ACTION_DISMISSED}: that one is the permanent notification being + * swiped, and means "stop listening". Dismissing an alarm means "I have read it", and + * turning the whole feature off because somebody answered it would be the worst possible + * reading of that gesture. + */ + private static final String ACTION_SILENCE_ALARM = + "dev.wander.opentagviewer.SILENCE_LEFT_BEHIND"; + + /** + * Channel for the left-behind alert, which is loud on purpose - see {@link #alertLeftBehind}. + * + *

The suffix is not decoration. A notification channel is immutable once created: + * changing the sound or the vibration in code does nothing for anybody who already has the + * old one, and there is no way to update it. The only way to change how an alert sounds is + * to publish a new channel, so the id carries a version. + */ + private static final String ALERT_CHANNEL_ID = "tag_left_behind_chosen_sound"; + + /** Earlier {@link #ALERT_CHANNEL_ID} values, deleted so they stop appearing in settings. */ + private static final List RETIRED_ALERT_CHANNEL_IDS = + List.of("tag_left_behind", "tag_left_behind_alarm"); + + /** + * How often the left-behind rule is evaluated. + * + *

This is latency, not work. The check is arithmetic over a handful of tags and + * touches the radio only for one that has gone quiet - but whatever it is, it is added to + * every alert. At a minute it was the largest single delay in the chain, longer than the + * silence it was watching for. + * + *

Five seconds because the silence to wait for is now the user's to choose and goes as + * low as ten - see {@code UserSettings.LEFT_BEHIND_AFTER_SECONDS_MIN}. A tick coarser than + * the setting makes the setting a lie: at fifteen, asking for ten and asking for fifteen + * produced the same alert at the same moment. The two reads it costs are a query against a + * tiny table and an in-memory preferences lookup, next to a radio that is scanning + * continuously the whole time either way. + */ + private static final long CHECK_INTERVAL_MS = 5_000L; + + /** What is known about a tag right now: heard since when, and where it turned up. */ + private static final class Presence { + private long lastHeardMs; + private final Double appearedLatitude; + private final Double appearedLongitude; + private boolean gone; + + private Presence(final long lastHeardMs, final Double latitude, final Double longitude) { + this.lastHeardMs = lastHeardMs; + this.appearedLatitude = latitude; + this.appearedLongitude = longitude; + } + } + + /** + * Per tag, when it was last heard and where this phone was then. + * + *

In memory rather than in the database, and lost on a restart on purpose: the rule is + * about a walk somebody is taking right now. A stale entry from before a reboot would fire + * as soon as the service came back somewhere else, which is the phone having moved rather + * than a tag having been left. + */ + private final Map presence = new ConcurrentHashMap<>(); + + private BeaconRepository beaconRepo; + + /** The key material the watch was started with, for the verification scan. */ + private Map accessoryJsonByBeaconId = Map.of(); + + /** + * What to call each tag, read once when the watch starts. + * + *

The same name the screens show - the user's own nickname where they set one, Apple's + * otherwise. An alert that names a beacon id tells somebody a tag is missing without telling + * them which, which is most of the message gone. + */ + private Map namesByBeaconId = Map.of(); + + /** + * The tags their owner has asked to be warned about, re-read on every check. + * + *

Held as the permissions because undecided means no - see + * {@code UserBeaconOptions.alertOnSeparation}. Every other tag is still scanned for and still + * recorded; only the noise is off, and it stays off until somebody asks for it by name. + * + *

Re-read rather than read once at startup. The switch lives in the app and this + * runs in a service that outlives it, so a set read when the watch started is a snapshot of + * what the user wanted before they went to change it. Reading it once meant turning the + * switch on did nothing at all until the service happened to restart, which from the outside + * is indistinguishable from the feature being broken. + */ + private volatile Set alertsOn = Set.of(); + + /** + * The silence a tag has to keep before it is worth checking, in milliseconds. + * + *

Re-read alongside {@link #alertsOn} and for the same reason: it is the user's to change + * from a screen that this service outlives. + */ + private volatile long quietForMs = LeftBehind.QUIET_FOR_MS; + + /** The user's chosen alarm sound, re-read with the rest. Empty means the system default. */ + private volatile String alarmSoundUri = ""; + + /** Plays that sound, on repeat, until the alert is answered. */ + private LeftBehindAlarm alarm; + + /** + * Whether the settings have been read yet in this service's life. + * + *

Only so the first read is logged even when it agrees with the defaults. "Nothing + * changed" and "the check never ran" produce the same silence in a log otherwise, and + * telling those two apart was the whole difficulty the last time this was wrong. + */ + private boolean haveReadSettings = false; + private AccessorySightingPersister sightingPersister; + private PhoneLocation phoneLocation; + + @Nullable + private Disposable watch; + + /** The periodic left-behind check, running for as long as the service does. */ + @Nullable + private Disposable leftBehindCheck; + + /** Starts the service, or does nothing if it is already running. */ + public static void start(final Context context) { + final Intent intent = new Intent(context.getApplicationContext(), NearbyScanService.class); + context.getApplicationContext().startForegroundService(intent); + } + + /** Stops the service and its scan. Safe to call when it is not running. */ + public static void stop(final Context context) { + final Intent intent = new Intent(context.getApplicationContext(), NearbyScanService.class); + context.getApplicationContext().stopService(intent); + } + + @Nullable + @Override + public IBinder onBind(final Intent intent) { + return null; + } + + @Override + public void onCreate() { + super.onCreate(); + + this.beaconRepo = new BeaconRepository( + OpenTagViewerDatabase.getInstance(this.getApplicationContext())); + this.phoneLocation = new CachedPhoneLocation( + new FusedPhoneLocation(this.getApplicationContext())); + this.sightingPersister = new AccessorySightingPersister(this.beaconRepo); + this.alarm = new LeftBehindAlarm(this.getApplicationContext()); + } + + @Override + public int onStartCommand(final Intent intent, final int flags, final int startId) { + // Logged because a restart wipes the presence map, and a tag heard again afterwards is + // then a fresh arrival that can alert a second time. Two alerts for one departure looked + // like a false alarm and could not be told apart from one after the fact. + Log.i(TAG, "onStartCommand: action=" + (intent == null ? "restart" : intent.getAction())); + + if (intent != null && ACTION_DISMISSED.equals(intent.getAction())) { + this.turnBackgroundScanningOff(); + return START_NOT_STICKY; + } + + if (intent != null && ACTION_SILENCE_ALARM.equals(intent.getAction())) { + this.alarm.stop(); + // Falls through to goToForeground below rather than returning: the service is still + // meant to be listening, and returning here would leave it started without the + // notification the platform requires it to have. + } + + this.goToForeground(); + + if (this.watch == null || this.watch.isDisposed()) { + this.startWatching(); + } + + // Restarted if the system kills it for memory, which is what somebody who turned this + // on is asking for. Without a redelivered intent: there is no work in it, the state + // lives in the setting. + return START_STICKY; + } + + @Override + public void onDestroy() { + if (this.watch != null && !this.watch.isDisposed()) { + this.watch.dispose(); + } + this.watch = null; + if (this.leftBehindCheck != null && !this.leftBehindCheck.isDisposed()) { + this.leftBehindCheck.dispose(); + } + this.leftBehindCheck = null; + if (this.alarm != null) { + // Nothing else would: the player holds no reference to the service, so a sounding + // alarm would outlive the thing that started it. + this.alarm.stop(); + } + super.onDestroy(); + } + + /** + * Subscribes the watch, over every tag that has usable key material. + * + *

Silent when it cannot run - no Bluetooth permission, radio off, or nothing backfilled + * yet - for the same reason the screens are: there is nothing the user can do about it from + * here, and a service that cannot scan should sit quietly rather than complain. + */ + private void startWatching() { + if (!BlePermissions.granted(this)) { + Log.d(TAG, "Not scanning in the background: BLE permission not granted"); + return; + } + + this.watch = this.beaconRepo.getAllBeacons() + .subscribeOn(Schedulers.io()) + .subscribe(beacons -> { + this.namesByBeaconId = readNames(beacons); + this.watchThese(keyMaterialOf(beacons)); + }, error -> Log.w(TAG, "Could not read the tags to watch for", error)); + } + + /** + * The key material worth listening for, keyed by beacon. + * + *

Retired tags are left out - there is nothing to listen for - but tags the network gave + * up on are kept: those stopped being findable over Apple's network, and hearing one + * directly is exactly what could still find it. + */ + private static Map keyMaterialOf(final List beacons) { + final Map byBeaconId = new HashMap<>(); + + for (final BeaconData beacon : beacons) { + final OwnedBeacon owned = beacon.getOwnedBeaconInfo(); + + if (owned == null || owned.isRemoved + || owned.accessoryJson == null || owned.accessoryJson.isEmpty()) { + continue; + } + byBeaconId.put(beacon.getBeaconId(), owned.accessoryJson); + } + + return byBeaconId; + } + + /** + * Display names, through the same parser the screens use. + * + *

Doing it here rather than reading a column: a name can come from the user's override, + * from Apple's naming record, or out of the accessory JSON for a tag that was never in an + * account, and {@code BeaconDataParser} is where that precedence already lives. A second + * implementation of it would eventually disagree with the one on screen. + */ + private static Map readNames(final List beacons) { + final Map names = new HashMap<>(); + + try { + for (final BeaconInformation information : BeaconDataParser.parse(beacons)) { + if (information.getName() != null && !information.getName().isBlank()) { + names.put(information.getBeaconId(), information.getName()); + } + } + } catch (final Exception e) { + // A tag with no name still deserves its alert, and the beacon id is at least true. + Log.w(TAG, "Could not read the tag names; alerts will name beacon ids", e); + } + + return names; + } + + private void watchThese(final Map accessoryJsonByBeaconId) { + if (accessoryJsonByBeaconId.isEmpty()) { + Log.d(TAG, "Not scanning in the background: no tags with key material"); + return; + } + + this.accessoryJsonByBeaconId = accessoryJsonByBeaconId; + + this.watch = new NearbyTagWatcher( + AppDependencies.accessoryMacResolver(), + (sighting, mac) -> { + this.sightingPersister.onSighting(sighting, mac); + this.noteHeard(sighting.getBeaconId()); + }, + android.bluetooth.le.ScanSettings.SCAN_MODE_BALANCED) + .watch(this.getApplicationContext(), accessoryJsonByBeaconId) + .subscribe( + sighting -> { }, + error -> Log.w(TAG, "Background watch ended with an error", error), + () -> Log.i(TAG, "Background watch ended")); + + Log.i(TAG, "Left-behind check starting, every " + (CHECK_INTERVAL_MS / 1000) + "s"); + + this.leftBehindCheck = Observable + .interval(CHECK_INTERVAL_MS, CHECK_INTERVAL_MS, TimeUnit.MILLISECONDS, + Schedulers.io()) + .subscribe(tick -> this.checkForLeftBehind(), + error -> Log.w(TAG, "The left-behind check stopped", error)); + } + + /** + * Notes that a tag was heard, and reads the position only if it had been away. + * + *

Nothing is read or written while a tag keeps being heard. A tag in range is with + * whoever is holding the phone, so a position taken then describes where the user + * went - it tracks a person rather than a thing, and records "still here" over and over. The + * two moments that carry information are the edges: a tag turning up somewhere, and a tag + * going quiet. + * + *

Turning up again also re-arms the alert. A tag that comes back is one that came along, + * and the next time it goes quiet is a new event worth its own alert. + */ + private void noteHeard(final String beaconId) { + final long now = System.currentTimeMillis(); + final Presence known = this.presence.get(beaconId); + + if (known != null && !known.gone) { + known.lastHeardMs = now; + return; + } + + final PhoneLocation.Fix fix = this.phoneLocation.lastKnown(); + + this.presence.put(beaconId, new Presence(now, + fix == null ? null : fix.getLatitude(), + fix == null ? null : fix.getLongitude())); + + if (fix != null) { + this.beaconRepo.recordLocalSighting(beaconId, fix.getLatitude(), fix.getLongitude(), + fix.getAccuracyMetres(), 0, now) + .subscribe(written -> { }, error -> + Log.w(TAG, "Could not record where beaconId=" + beaconId + + " turned up", error)); + } + + // Answered by the tag itself: whatever the alert was about has resolved, and a siren + // going while the thing it is about is back in earshot is just wrong. + this.alarm.stop(); + + Log.d(TAG, "beaconId=" + beaconId + " is in range again"); + } + + /** + * Alerts once for each tag that has gone quiet while this phone moved on. + * + *

Needs a position now as well as then - without one there is no way to tell walking away + * from standing still, and silence on its own is not worth waking somebody for. See + * {@link LeftBehind} for the rule and why it is deliberately hard to satisfy. + */ + private void checkForLeftBehind() { + final long now = System.currentTimeMillis(); + + // One small query against UserBeaconOptions per tick, on the IO scheduler this runs on. + // Kept cheap enough to do unconditionally rather than guessing when it might have moved. + // A failure keeps the previous answer: stale permissions beat none at all. + try { + final Set wanted = this.beaconRepo.getBeaconsWithAlertsOn().blockingFirst(); + if (!wanted.equals(this.alertsOn) || !this.haveReadSettings) { + // Logged on change rather than every tick: this is the one place that says the + // switch in the app actually reached the service, which is exactly what is + // invisible when it does not. + Log.i(TAG, "Left-behind alerts are now wanted for " + wanted.size() + " tag(s)"); + this.alertsOn = wanted; + } + + final UserSettings settings = new UserSettingsRepository( + UserSettingsDataStore.getInstance(this)).getUserSettings(); + + final long configured = settings.resolveLeftBehindAfterSeconds() * 1000L; + if (configured != this.quietForMs || !this.haveReadSettings) { + Log.i(TAG, "Left-behind silence is now " + (configured / 1000) + "s"); + this.quietForMs = configured; + } + + this.alarmSoundUri = settings.getLeftBehindSoundUri() == null + ? "" : settings.getLeftBehindSoundUri(); + this.haveReadSettings = true; + } catch (final Exception couldNotRead) { + Log.w(TAG, "Could not re-read the left-behind settings", couldNotRead); + } + + for (final Map.Entry entry : this.presence.entrySet()) { + final Presence known = entry.getValue(); + + if (known.gone || now - known.lastHeardMs < this.quietForMs) { + continue; + } + + // Quiet long enough to be worth checking. This is the second of the two edges, and + // the only other moment the position is worth reading. + known.gone = true; + + if (!this.alertsOn.contains(entry.getKey())) { + // Still scanned for, still recorded - the owner has just not asked to be woken + // for this one, which is the default. Skipping the verification scan too, since + // nothing would be done with the answer. + continue; + } + + // **A missing position must not swallow the alert.** It used to, left over from when + // distance was half the rule; the verification scan decides now, and "your keys are + // not with you" is worth saying whether or not the phone can say where. It also + // happens to be the state the service is in after a reboot until the app is next + // opened, so the gate turned the whole feature off exactly when it was meant to be + // working on its own. + this.verifyThenAlert(entry.getKey(), known, this.phoneLocation.lastKnown(), now); + } + } + + /** + * Listens hard for one tag before saying it is gone. + * + *

Silence from a low-power scan is not evidence. {@code SCAN_MODE_LOW_POWER} + * listens for roughly half a second in five, so a tag in a pocket with a body in the way + * misses windows in runs - a gap of 66 seconds was measured while carrying one, against a + * threshold of 90. Any threshold short enough to be useful while walking out of a cafe sits + * inside that noise, and the alert that fired 20 minutes into a walk was exactly this: the + * tag was in the pocket the whole time. + * + *

So the timer no longer decides. When it runs out, the radio listens properly for a few + * seconds - the same targeted, low-latency scan the ring button uses - and only silence + * then earns an alert. It costs one short burst per suspicion instead of running the + * radio hard all day, and it makes a short threshold safe: about a minute of quiet plus a + * few seconds of listening, rather than five minutes of waiting and still being wrong. + */ + private void verifyThenAlert(final String beaconId, final Presence known, + @Nullable final PhoneLocation.Fix here, final long nowMs) { + + final String accessoryJson = this.accessoryJsonByBeaconId.get(beaconId); + if (accessoryJson == null) { + return; + } + + final Map candidates = + AppDependencies.accessoryMacResolver().currentMacAddresses(accessoryJson); + + if (candidates == null || candidates.isEmpty()) { + return; + } + + NearbyAccessoryScanner + .findNearby(this.getApplicationContext(), candidates.keySet(), VERIFY_SCAN_MS) + .subscribeOn(Schedulers.io()) + .subscribe( + device -> { + // It was a gap. Put the tag back to present so the next silence is + // judged from here rather than from before the burst. + known.gone = false; + known.lastHeardMs = System.currentTimeMillis(); + Log.d(TAG, "beaconId=" + beaconId + + " answered the verification scan; no alert"); + }, + notNearby -> { + if (here != null) { + this.recordContactLost(beaconId, known, here, nowMs); + } + this.alertLeftBehind(beaconId, known.lastHeardMs); + }); + } + + /** + * How long the verification scan listens. + * + *

A tag in range advertises every second or two, so a few seconds of low-latency + * listening hears it several times over. Long enough to be conclusive, short enough that the + * burst costs nothing next to the day the radio spends idling. + */ + private static final long VERIFY_SCAN_MS = 6_000L; + + /** + * Writes where contact with a tag was lost, as well as it can be known. + * + *

The position is where the phone is now, and the accuracy says how little that is + * worth. Contact could have been lost anywhere in the quiet window - five minutes of + * walking is several hundred metres - so the row claims that whole radius rather than the + * metres the fix itself would claim. A tight circle drawn around where somebody noticed the + * silence would be the app inventing a place it never observed. + * + *

Nothing is written when the phone has not moved: the tag going quiet on a desk beside + * somebody is a radio gap, not a place worth recording. + */ + private void recordContactLost(final String beaconId, final Presence known, + final PhoneLocation.Fix here, final long nowMs) { + + if (known.appearedLatitude != null && LocalFixWorthKeeping.metresBetween( + known.appearedLatitude, known.appearedLongitude, + here.getLatitude(), here.getLongitude()) < LocalFixWorthKeeping.MOVED_METRES) { + return; + } + + final long couldBeAnywhereWithin = here.getAccuracyMetres() + + Math.round((this.quietForMs / 1000.0) * WALKING_METRES_PER_SECOND); + + this.beaconRepo.recordLocalSighting(beaconId, here.getLatitude(), here.getLongitude(), + couldBeAnywhereWithin, 0, nowMs) + .subscribe(written -> { }, error -> + Log.w(TAG, "Could not record where contact with beaconId=" + beaconId + + " was lost", error)); + } + + /** Walking pace, for turning the quiet window into the radius it implies. */ + private static final double WALKING_METRES_PER_SECOND = 1.4; + + /** + * The one notification in this app that is allowed to interrupt. + * + *

High importance, with sound. Everything else here is a status line somebody can + * find when they go looking; this is the opposite - it is only useful in the half minute + * while walking away is still reversible, and a silent entry in the shade would be read + * hours later at home. That is also why the rule behind it is strict: an alert that cries + * wolf gets switched off, and then it is not there on the day it matters. + */ + private void alertLeftBehind(final String beaconId, final long lastHeardMs) { + final NotificationManager manager = this.getSystemService(NotificationManager.class); + + if (manager.getNotificationChannel(ALERT_CHANNEL_ID) == null) { + final NotificationChannel channel = new NotificationChannel( + ALERT_CHANNEL_ID, + this.getString(R.string.left_behind_channel), + NotificationManager.IMPORTANCE_HIGH); + channel.setDescription(this.getString(R.string.left_behind_channel_description)); + + // **Silent on purpose, and this is not the alert going quiet.** The sound is played + // by LeftBehindAlarm instead, on the alarm stream and on repeat. A channel's sound + // is fixed when the channel is created and cannot be changed afterwards, so a sound + // the user picks cannot live here; and a channel plays it once, which is a chime, + // and a chime from a pocket during a walk is the thing that gets missed. + channel.setSound(null, null); + + // Long enough to be felt through a coat, and unlike a message buzz. + channel.enableVibration(true); + channel.setVibrationPattern(new long[] {0, 500, 250, 500, 250, 800}); + + manager.createNotificationChannel(channel); + } + + final Intent open = new Intent(this, MapsActivity.class) + .putExtra("beaconId", beaconId); + + final PendingIntent show = PendingIntent.getActivity( + this, beaconId.hashCode(), open, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + + final Intent quiet = new Intent(this, NearbyScanService.class) + .setAction(ACTION_SILENCE_ALARM); + final PendingIntent silence = PendingIntent.getService( + this, 2, quiet, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + + final CharSequence howLongAgo = DateUtils.getRelativeTimeSpanString( + lastHeardMs, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS); + + final Notification alert = new NotificationCompat.Builder(this, ALERT_CHANNEL_ID) + .setContentTitle(this.getString(R.string.left_behind_title, + this.namesByBeaconId.getOrDefault(beaconId, beaconId))) + .setContentText(this.getString(R.string.left_behind_text, howLongAgo)) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setPriority(NotificationCompat.PRIORITY_MAX) + // An alarm rather than a reminder: it says to the system, and to anything + // summarising notifications, that this is time-critical rather than something + // to read later. + .setCategory(NotificationCompat.CATEGORY_ALARM) + .setContentIntent(show) + // Swiping it away is the answer to it, so that is where the sound stops. + .setDeleteIntent(silence) + .setAutoCancel(true) + .build(); + + // One notification per tag rather than one that replaces the last: leaving two things + // behind is two things to go back for. + manager.notify(beaconId.hashCode(), alert); + + this.alarm.start(this.alarmSoundUri); + + if (this.alarm.isAlarmStreamSilent()) { + // Worth saying out loud rather than leaving as a mystery: the alert did everything + // it was asked to and still made no noise, and the reason is not in this app. + Log.w(TAG, "Alarm volume is at zero; the left-behind alert will be silent"); + } + + Log.i(TAG, "Alerted that beaconId=" + beaconId + " looks left behind"); + } + + /** + * Turns the setting off and stops, after the notification was swiped away. + * + *

The setting is written, not just the service stopped. Otherwise the switch in + * Settings would still read as on while nothing was running, and the next app start would + * bring the service back - which is the same swipe undone, without the user asking for it. + */ + private void turnBackgroundScanningOff() { + Log.i(TAG, "Notification dismissed; turning background scanning off"); + + Schedulers.io().scheduleDirect(() -> { + try { + final UserSettingsRepository settingsRepo = + new UserSettingsRepository(UserSettingsDataStore.getInstance(this)); + final UserSettings settings = settingsRepo.getUserSettings(); + + settings.setScanInBackground(false); + settingsRepo.storeUserSettings(settings).blockingAwait(); + } catch (final Exception e) { + Log.w(TAG, "Could not turn the background scan setting off", e); + } + }); + + this.stopSelf(); + } + + /** + * The permanent notification, which is what buys the right to keep scanning. + * + *

Low importance: it must be visible, and it must not make a sound or push anything else + * off the screen. Tapping it opens the map, because "what is this doing" and "what has it + * found" are the same question. + * + *

Not {@code setSilent}, which is a different thing from a quiet channel. The + * channel's own {@code IMPORTANCE_LOW} already means no sound. {@code setSilent} additionally + * files the notification under "Silent", where it gets no status bar icon at all - so the + * service ran with nothing to see unless somebody pulled the shade down, which defeats the + * one thing a permanent notification is for. + */ + private void goToForeground() { + final NotificationManager manager = this.getSystemService(NotificationManager.class); + + // **Tidying up after our own versioning.** Each change to how the alert sounds had to + // publish a new channel, because a channel's settings are fixed once created. The old + // ones are unused but stay in the user's notification settings forever, so each retired + // id would leave another dead entry there under a name that still looks live. + for (final String retired : RETIRED_ALERT_CHANNEL_IDS) { + manager.deleteNotificationChannel(retired); + } + + if (manager.getNotificationChannel(CHANNEL_ID) == null) { + final NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + this.getString(R.string.background_scan_channel), + NotificationManager.IMPORTANCE_LOW); + channel.setDescription(this.getString(R.string.background_scan_channel_description)); + manager.createNotificationChannel(channel); + } + + final PendingIntent open = PendingIntent.getActivity( + this, 0, new Intent(this, MapsActivity.class), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + + final Intent dismissed = new Intent(this, NearbyScanService.class) + .setAction(ACTION_DISMISSED); + final PendingIntent onSwipe = PendingIntent.getService( + this, 1, dismissed, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + + final Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(this.getString(R.string.background_scan_notification_title)) + .setContentText(this.getString(R.string.background_scan_notification_text)) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentIntent(open) + .setDeleteIntent(onSwipe) + .setOngoing(true) + .build(); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + this.startForeground(NOTIFICATION_ID, notification); + return; + } + + // Android 14 wants the type declared at the call site as well as in the manifest. + try { + this.startForeground(NOTIFICATION_ID, notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION + | ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE); + } catch (final SecurityException notEligibleForLocation) { + // **Starting at boot lands here, and it is not a misconfiguration.** Location is a + // foreground-only permission: BOOT_COMPLETED exempts the app from the ban on + // starting a foreground service from the background, but not from the rule that it + // may not *use* a while-in-use permission with nothing visible. Asking for the + // location type then throws, and the throw killed the service - which START_STICKY + // dutifully restarted, into the same throw, until Android gave up on the app. + // + // Scanning is a connected-device job on its own terms, so it carries on as one. The + // position reads simply return nothing until the app is next opened, which + // FusedPhoneLocation already treats as an ordinary answer. + Log.i(TAG, "Not eligible for the location type here; running as connected-device", + notEligibleForLocation); + + this.startForeground(NOTIFICATION_ID, notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE); + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java index 18ecffe9..e0ca3536 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java @@ -11,20 +11,46 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class WindowPaddingUtil { + /** - * In UIs like Samsung Galaxy S25 Ultra, the top padding under the top list of icons in the UI - * (the notifications, time, battery, ...) is absent, which results in a top bar that is too small + * Keep a screen's content clear of both system bars. + * + *

The bottom is the one that gets forgotten, and it is the one people notice. The + * theme draws under a transparent navigation bar, so anything at the bottom of a screen ends + * up beneath it - and unlike a clipped heading, a button underneath the bar is not merely + * ugly. It is hard to press, or impossible: the bar takes the touch. Reported on a Samsung + * phone as "any button we put at the bottom of the page is barely to not clickable", with a + * screenshot of the keychain unlock screen's Unlock button sitting behind the gesture pill. + * + *

Both bars in one call, because two calls is what let this happen. There used to + * be a top-only helper; it was applied to seven screens and the bottom inset to one, and + * nothing about writing the first suggests you owe the second. Anything that pads for the + * status bar has the same problem at the other end of the screen, so the top-only version + * is gone rather than left available to be called again. * - * @param rootView The view that holds all of the UI for a given activity. + *

The view's own padding is kept and the insets are added to it, so a layout that already + * asks for breathing room does not lose it - and the values are read once, here, rather than + * inside the listener. Insets arrive more than once (a rotation, a keyboard, switching to + * three-button navigation), and adding to the current padding each time would grow the gap + * on every delivery. + * + *

Not for a screen that deliberately draws edge to edge. The map is the example: it wants + * tiles under the bar and pads only the card row above it, with + * {@link #insertUIBottomPadding}. */ - public static void insertUITopPadding(View rootView) { - ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> { - Insets statusBarInsets = insets.getInsets(WindowInsetsCompat.Type.statusBars()); + public static void insetForSystemBars(final View view) { + final int ownLeft = view.getPaddingLeft(); + final int ownTop = view.getPaddingTop(); + final int ownRight = view.getPaddingRight(); + final int ownBottom = view.getPaddingBottom(); + + ViewCompat.setOnApplyWindowInsetsListener(view, (v, insets) -> { + final Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); v.setPadding( - 0, - statusBarInsets.top, - 0, - 0 + ownLeft + bars.left, + ownTop + bars.top, + ownRight + bars.right, + ownBottom + bars.bottom ); return insets; }); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/error/ErrorReportActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/error/ErrorReportActivity.java index 4f3c9401..dc0d4e8c 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/error/ErrorReportActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/error/ErrorReportActivity.java @@ -26,6 +26,7 @@ import java.io.Writer; import java.nio.charset.StandardCharsets; +import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; import dev.wander.android.opentagviewer.BuildConfig; import dev.wander.android.opentagviewer.R; import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; @@ -108,6 +109,11 @@ public static String describe(final Throwable error) { protected void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); this.setContentView(R.layout.activity_error_report); + // This screen handled neither bar - it was missed when the others were fixed, because it + // never called the old top-only helper either, so there was nothing to find and replace. + // Its Close and Share buttons are the last things on a scrolling page, which is exactly + // where the navigation bar lands. + WindowPaddingUtil.insetForSystemBars(this.findViewById(R.id.error_report_root)); if (this.getSupportActionBar() != null) { this.getSupportActionBar().hide(); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/TagCardHelper.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/TagCardHelper.java index 112d11c4..2be3e106 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/TagCardHelper.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/TagCardHelper.java @@ -3,10 +3,13 @@ import static android.view.View.GONE; import static android.view.View.VISIBLE; +import android.content.res.ColorStateList; import android.util.Log; import android.widget.FrameLayout; import android.widget.ImageView; +import android.widget.TextView; +import com.google.android.material.color.MaterialColors; import com.google.android.material.progressindicator.CircularProgressIndicator; import java.util.Map; @@ -36,6 +39,58 @@ public static void toggleRefreshLoading(FrameLayout container, boolean isLoading } } + /** + * Shows whether continuous ping (repeated scan + play-sound-nearby) is running for this + * card's tag - the icon becomes a stop glyph, tinted with the theme's error colour so it + * reads as "tap to stop" at a glance, and the label swaps to match. + */ + public static void toggleRingActive(FrameLayout container, boolean active) { + try { + ImageView icon = container.findViewById(R.id.perform_ring_icon); + TextView label = container.findViewById(R.id.ringText); + + icon.setImageResource(active ? R.drawable.close_24px : R.drawable.volume_24); + icon.setImageTintList(ColorStateList.valueOf(active + ? MaterialColors.getColor(container, com.google.android.material.R.attr.colorError) + : MaterialColors.getColor(container, com.google.android.material.R.attr.colorOnSurfaceVariant))); + label.setText(active ? R.string.stop_ringing : R.string.do_ring); + } catch (Exception e) { + Log.e(TAG, "Failure while trying to toggle the ring button's active state", e); + } + } + + /** + * Updates only the ring button's label text, leaving its icon/tint alone - for showing + * continuous ping's current phase (scanning/connecting/sending) between the on/off states + * {@link #toggleRingActive} sets. + */ + public static void setRingLabel(FrameLayout container, CharSequence text) { + try { + TextView label = container.findViewById(R.id.ringText); + label.setText(text); + } catch (Exception e) { + Log.e(TAG, "Failure while trying to update the ring button's label", e); + } + } + + /** + * Swaps the ring icon for a spinner while a scan/connect/trigger attempt is actually in + * flight - the label alone ("Scanning...", "Connecting...") was mistaken for a stall, + * since it can sit on screen for several seconds with nothing else moving. Independent of + * {@link #toggleRingActive}: this toggles per attempt, that toggles per on/off. + */ + public static void setRingLoading(FrameLayout container, boolean loading) { + try { + ImageView icon = container.findViewById(R.id.perform_ring_icon); + CircularProgressIndicator progressIndicator = container.findViewById(R.id.ring_loading_indicator); + + icon.setVisibility(loading ? GONE : VISIBLE); + progressIndicator.setVisibility(loading ? VISIBLE : GONE); + } catch (Exception e) { + Log.e(TAG, "Failure while trying to toggle the loading status on the ring button", e); + } + } + public static void toggleRefreshLoadingAll(Map containers, boolean isLoading) { try { for (var frameLayout : containers.values()) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/LeftBehind.java b/app/src/main/java/dev/wander/android/opentagviewer/util/LeftBehind.java new file mode 100644 index 00000000..6dedd7d1 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/LeftBehind.java @@ -0,0 +1,78 @@ +package dev.wander.android.opentagviewer.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Whether a tag looks left behind: heard until recently, quiet now, and the phone has moved on. + * + *

Two conditions, and the second one is what makes this usable. Silence alone proves + * nothing - a tag in a bag, behind a body, or simply quiet during a low-power scan window is + * indistinguishable from one left on a table. Requiring the phone to have moved away since + * the last sighting turns an absence into something worth saying out loud: you are somewhere + * else now, and the tag is not with you. + * + *

Not the same thing as the tag reporting itself separated. A Find My accessory says + * in its own advertisement whether its owner device is near - see {@code FindMyAdvertisement} - + * but for somebody with no Apple device at all, every tag they own is separated all the time. + * That signal answers "is this tag away from its owner's iPhone", which is not the question + * anybody is asking when they walk out of a cafe. + * + *

No Android in here, and the clock and position are parameters, so the rule is covered by a + * JVM test rather than by leaving tags in cafes. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class LeftBehind { + + /** + * How long a tag must be unheard before it counts as gone. + * + *

A trigger, not evidence. It used to be the whole rule, which meant it had to be + * long enough to outlast a radio gap - 90 seconds, and a 66-second gap was measured while + * simply carrying a tag in a pocket. That is late enough to be useless: somebody who has + * left a cafe wants to know before the next street, not after it. + * + *

Since {@code NearbyScanService} verifies with a targeted scan before alerting, silence + * no longer has to prove anything - it only has to be worth checking. Being wrong here costs + * a few seconds of listening, so it can afford to be wrong often. + */ + public static final long QUIET_FOR_MS = 30 * 1000L; + + /** + * How far the phone must have moved from where the tag was last heard. + * + *

Well past the range at which the tag would still be audible, so this cannot fire while + * somebody is still in the same room as it. Roughly the distance from a table to the far + * side of the building, or a minute's walk. + */ + public static final double MOVED_AWAY_METRES = 100.0; + + /** + * True when this tag should be reported as left behind. + * + * @param lastHeardMs when the tag was last heard, or null if it never has been - a tag + * this phone has not met is not one somebody walked away from. + * @param lastHeardLatitude where the phone was then, or null if it had no fix. Without one + * there is no way to tell moving away from standing still, and the + * silence alone is not enough to alert on. + */ + public static boolean looksLeftBehind( + final Long lastHeardMs, + final Double lastHeardLatitude, + final Double lastHeardLongitude, + final long nowMs, + final double latitude, + final double longitude) { + + if (lastHeardMs == null || lastHeardLatitude == null || lastHeardLongitude == null) { + return false; + } + + if (nowMs - lastHeardMs < QUIET_FOR_MS) { + return false; + } + + return LocalFixWorthKeeping.metresBetween( + lastHeardLatitude, lastHeardLongitude, latitude, longitude) >= MOVED_AWAY_METRES; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/LocalFixWorthKeeping.java b/app/src/main/java/dev/wander/android/opentagviewer/util/LocalFixWorthKeeping.java new file mode 100644 index 00000000..817a40bb --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/LocalFixWorthKeeping.java @@ -0,0 +1,99 @@ +package dev.wander.android.opentagviewer.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Whether a position heard over Bluetooth is worth writing down, given the last one that was. + * + *

The sighting rate and the useful position rate are two different things. A tag in + * range is heard every second or two, and the sighting callback is already throttled to once a + * minute per tag - right for a battery reading, which costs one row that is overwritten. A + * position costs a row that is kept, and is reverse-geocoded when it is shown. A tag + * sitting beside somebody all evening would write several hundred rows describing the same + * spot, and the history it exists to build would become unreadable in the process. + * + *

So a position is kept when it says something the last one did not: the phone has moved far + * enough that this is a different place, or enough time has passed that "still here" is itself + * worth recording. + * + *

No Android in here - {@code Location.distanceBetween} would drag the whole rule onto a + * device - so both the distance and the rule are covered by a JVM test. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class LocalFixWorthKeeping { + + /** + * How far the phone must have moved before the same tag earns another row. + * + *

Bluetooth range is the yardstick, not GPS precision. A match means the tag was within + * roughly ten metres of the phone, so two fixes closer together than this describe the same + * place as far as anybody looking for the tag is concerned. Below it the rows would differ + * only by GPS noise, which is itself several metres when standing still. + */ + public static final double MOVED_METRES = 25.0; + + /** + * How long the same place stays worth re-recording. + * + *

Not zero, because "the keys were still here an hour later" is information a history + * should carry - it is the difference between a tag last seen at home this morning and one + * that has been there all day. Long enough that a stationary tag writes a couple of dozen + * rows a day rather than a thousand. + */ + public static final long AGAIN_AFTER_MS = 15 * 60 * 1000L; + + /** + * Mean Earth radius in metres, for {@link #metresBetween}. + * + *

A sphere, not the ellipsoid the map projects on. Over the distances this rule cares + * about - tens of metres - the two disagree by centimetres, and the threshold above is a + * judgement call to within metres anyway. + */ + private static final double EARTH_RADIUS_M = 6_371_000.0; + + /** + * True when this fix should be written as a new report for the tag. + * + * @param lastMs when the last local report for this tag was written, or null if there + * is none - the first sighting of a tag is always worth keeping. + */ + public static boolean worthKeeping( + final Double lastLatitude, + final Double lastLongitude, + final Long lastMs, + final double latitude, + final double longitude, + final long nowMs) { + + if (lastMs == null || lastLatitude == null || lastLongitude == null) { + return true; + } + + if (nowMs - lastMs >= AGAIN_AFTER_MS) { + return true; + } + + return metresBetween(lastLatitude, lastLongitude, latitude, longitude) >= MOVED_METRES; + } + + /** + * Great-circle distance in metres between two coordinates, by the haversine formula. + * + *

Chosen over the flat-earth approximation because the latter needs a cosine correction + * that is easy to leave out, and gets worse the further from the equator the user happens to + * live - a bug nobody in the wrong hemisphere would ever report. + */ + public static double metresBetween( + final double lat1, final double lon1, final double lat2, final double lon2) { + + final double dLat = Math.toRadians(lat2 - lat1); + final double dLon = Math.toRadians(lon2 - lon1); + + final double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) + * Math.sin(dLon / 2) * Math.sin(dLon / 2); + + return EARTH_RADIUS_M * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/android/AppCryptographyUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/util/android/AppCryptographyUtil.java index 55b9a09e..14c0ef8d 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/android/AppCryptographyUtil.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/android/AppCryptographyUtil.java @@ -10,6 +10,7 @@ import static javax.crypto.Cipher.ENCRYPT_MODE; import android.security.keystore.KeyGenParameterSpec; +import android.util.Log; import android.util.Pair; import java.io.IOException; @@ -31,11 +32,14 @@ import javax.crypto.spec.GCMParameterSpec; import dev.wander.android.opentagviewer.db.AppCryptographyException; +import dev.wander.android.opentagviewer.db.MissingKeystoreKeyException; import lombok.Getter; import lombok.NonNull; import lombok.RequiredArgsConstructor; public final class AppCryptographyUtil { + + private static final String TAG = AppCryptographyUtil.class.getSimpleName(); // https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec#example:-aes-key-for-encryptiondecryption-in-gcm-mode // https://developer.android.com/privacy-and-security/cryptography // https://developer.android.com/reference/android/security/keystore/KeyProtection#example:-aes-key-for-encryptiondecryption-in-gcm-mode @@ -71,9 +75,23 @@ public synchronized AppEncryptedData encrypt(final byte[] dataToEncrypt, final S } } + /** + * Decryption never creates a key. A key made now cannot open anything written before, + * so generating one here can only turn a missing key into a failed decrypt - while putting + * the alias back, which hides the fact that it was ever gone. Absence is reported as + * {@link MissingKeystoreKeyException}, which is the one decryption failure that is somebody's + * device rather than this app's bug. + */ public synchronized byte[] decrypt(final byte[] dataToDecrypt, final byte[] iv, final String keystoreAlias) { + final SecretKey existing = this.existingKeyForAlias(keystoreAlias); + if (existing == null) { + throw new MissingKeystoreKeyException( + "There is no keystore key under " + keystoreAlias + " any more, so what was" + + " encrypted with it cannot be read"); + } + try { - SecretKey key = this.getKeyForAlias(keystoreAlias); + SecretKey key = existing; Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(DECRYPT_MODE, key, new GCMParameterSpec(AES_GMC_TAG_SIZE * 8, iv)); return cipher.doFinal(dataToDecrypt); @@ -86,6 +104,27 @@ public synchronized byte[] decrypt(@NonNull final AppEncryptedData data, final S return decrypt(data.getCipherText(), data.getIv(), keystoreAlias); } + /** + * The key under this alias, or null if there is not one. Never creates. + * + *

A keystore that cannot be opened at all is reported as absent too: it is the same + * situation for a caller, and throwing out of here would take down a screen on a path + * nobody can act on. + */ + private synchronized SecretKey existingKeyForAlias(@NonNull final String keystoreAlias) { + try { + final KeyStore keyStore = KeyStore.getInstance(ANDROID_KEYSTORE); + keyStore.load(null); + + final Key entry = keyStore.getKey(keystoreAlias, null); + return entry instanceof SecretKey ? (SecretKey) entry : null; + } catch (final Exception keystoreUnavailable) { + Log.w(TAG, "Could not read the keystore looking for " + keystoreAlias, + keystoreUnavailable); + return null; + } + } + private synchronized SecretKey getKeyForAlias(@NonNull final String keystoreAlias) throws KeyStoreException, CertificateException, IOException, NoSuchAlgorithmException, UnrecoverableEntryException, NoSuchProviderException, InvalidAlgorithmParameterException { KeyStore keyStore = KeyStore.getInstance(ANDROID_KEYSTORE); keyStore.load(null); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/android/CachedPhoneLocation.java b/app/src/main/java/dev/wander/android/opentagviewer/util/android/CachedPhoneLocation.java new file mode 100644 index 00000000..fdf7d23d --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/android/CachedPhoneLocation.java @@ -0,0 +1,111 @@ +package dev.wander.android.opentagviewer.util.android; + +import androidx.annotation.Nullable; + +import java.util.concurrent.TimeUnit; + +/** + * A {@link PhoneLocation} that answers from memory between reads. + * + *

Because every read lights the location indicator. Android shows it whenever an app + * touches location, and the sighting path touches it per sighting - once to attribute the + * position, once for the left-behind rule, for every tag in range. With the background service + * running that is a chip blinking in the status bar every few seconds, which reads as the app + * tracking somebody far more aggressively than it is. + * + *

It costs almost no accuracy, because the underlying read was never a fresh fix. + * {@code getLastLocation} hands back whatever position the system already holds; reading it more + * often does not make it newer. Caching changes how often this app asks, not how current + * the answer is. + * + *

The window is a minute, which is well inside the fifteen the write rule waits before + * recording a stationary tag again - so a cached fix cannot suppress a row that a fresh one + * would have written. + * + *

No Android in here on purpose: it decorates the seam rather than the implementation, so the + * expiry rule is covered by a JVM test. + */ +public class CachedPhoneLocation implements PhoneLocation { + + /** How long an answer is reused. See the class doc for why a minute is safe here. */ + static final long FRESH_FOR_MS = TimeUnit.MINUTES.toMillis(1); + + /** + * Metres per second of slack added to a cached fix per second of its age. + * + *

Because a stale position handed back at its original accuracy is a lie. Somebody + * walking covers roughly this much a second, so a minute-old fix can be eighty metres from + * where they are - reported as accurate to eight, which is what the map would draw and what + * anything comparing two reports would believe. + * + *

Walking pace rather than driving: this is an upper bound on the error for the case the + * feature is for, and inflating every fix to motorway distances would make an honest reading + * useless. A fix taken while driving is wider than this says, and the fix's own age is + * recorded either way. + */ + private static final double WALKING_METRES_PER_SECOND = 1.4; + + /** Injectable so the expiry is testable without waiting a minute. */ + interface Clock { + long nowMs(); + } + + private final PhoneLocation delegate; + private final Clock clock; + + @Nullable + private volatile Fix cached; + private volatile long cachedAtMs; + + /** Separate from the timestamp: null is a real answer here, so it must be tellable from + * "never asked". A sentinel timestamp made an empty cache look fresh. */ + private volatile boolean asked; + + public CachedPhoneLocation(final PhoneLocation delegate) { + this(delegate, System::currentTimeMillis); + } + + CachedPhoneLocation(final PhoneLocation delegate, final Clock clock) { + this.delegate = delegate; + this.clock = clock; + } + + @Nullable + @Override + public Fix lastKnown() { + final long now = this.clock.nowMs(); + + if (this.asked && now - this.cachedAtMs < FRESH_FOR_MS) { + return this.cached == null ? null : widenedByAge(this.cached, now - this.cachedAtMs); + } + + final Fix fresh = this.delegate.lastKnown(); + + // **A miss is remembered too.** Location being off, or no fix yet, is a state that lasts + // - retrying it per sighting would light the indicator exactly as often as succeeding, + // for an answer that is not going to change in the next few seconds. + this.cached = fresh; + this.cachedAtMs = now; + this.asked = true; + + return fresh; + } + + /** + * The same position, with the accuracy it can still honestly claim at this age. + * + *

Widening rather than refusing: a position good to a hundred metres is worth keeping and + * says so, while withholding it would leave the sighting with no place at all. The reader + * that cares - {@code BeaconCombinerUtil}, comparing two reports of the same moment - gets + * the number it needs to prefer the better one. + */ + private static Fix widenedByAge(final Fix fix, final long ageMs) { + final long slack = Math.round((ageMs / 1000.0) * WALKING_METRES_PER_SECOND); + + if (slack == 0) { + return fix; + } + + return new Fix(fix.getLatitude(), fix.getLongitude(), fix.getAccuracyMetres() + slack); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/android/FusedPhoneLocation.java b/app/src/main/java/dev/wander/android/opentagviewer/util/android/FusedPhoneLocation.java new file mode 100644 index 00000000..7feedb1a --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/android/FusedPhoneLocation.java @@ -0,0 +1,94 @@ +package dev.wander.android.opentagviewer.util.android; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.pm.PackageManager; +import android.location.Location; +import android.util.Log; + +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import com.google.android.gms.location.FusedLocationProviderClient; +import com.google.android.gms.location.LocationServices; +import com.google.android.gms.tasks.Tasks; + +import java.util.concurrent.TimeUnit; + +/** + * The real {@link PhoneLocation}: the cached fix Play services already holds. + * + *

Same client the map uses for its own blue dot, so this asks for nothing the app was not + * already granted and starts no new location request. + */ +public class FusedPhoneLocation implements PhoneLocation { + private static final String TAG = FusedPhoneLocation.class.getSimpleName(); + + /** + * How long to wait for a cached fix before giving up. + * + *

Short on purpose. {@code getLastLocation} answers from memory when there is anything to + * answer with, so a wait longer than this means something is wrong rather than slow - and + * this blocks a thread on the sighting path while it waits. + */ + private static final long WAIT_MS = 2_000L; + + private final Context context; + private final FusedLocationProviderClient client; + + public FusedPhoneLocation(final Context context) { + this.context = context.getApplicationContext(); + this.client = LocationServices.getFusedLocationProviderClient(this.context); + } + + @Nullable + @Override + @SuppressLint("MissingPermission") + public Fix lastKnown() { + if (!this.locationGranted()) { + // Not an error: the map asks for this permission, and somebody who declined it has + // said they do not want their position recorded. The sighting still records what it + // heard, minus the position. + return null; + } + + try { + final Location location = + Tasks.await(this.client.getLastLocation(), WAIT_MS, TimeUnit.MILLISECONDS); + + if (location == null) { + return null; + } + + // hasAccuracy() is false on a fix from a provider that does not report one. Zero + // would then be written as "accurate to the metre", which is a stronger claim than + // anything here can make - so it becomes the width of Bluetooth range instead, which + // is what hearing the tag actually established. + final long accuracy = location.hasAccuracy() + ? Math.round(location.getAccuracy()) + : BLUETOOTH_RANGE_M; + + return new Fix(location.getLatitude(), location.getLongitude(), accuracy); + } catch (final Exception e) { + Log.d(TAG, "No cached location available for this sighting", e); + return null; + } + } + + /** + * The accuracy claimed when the system reports none of its own. + * + *

Hearing a Find My advertisement is itself a distance measurement of sorts: the tag was + * in Bluetooth range. That is the weakest true statement available, so it is the right + * fallback - see {@code NearbyTagLabel} on why RSSI cannot narrow it further. + */ + private static final long BLUETOOTH_RANGE_M = 10L; + + private boolean locationGranted() { + return ContextCompat.checkSelfPermission(this.context, Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED + || ContextCompat.checkSelfPermission(this.context, Manifest.permission.ACCESS_COARSE_LOCATION) + == PackageManager.PERMISSION_GRANTED; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/android/PhoneLocation.java b/app/src/main/java/dev/wander/android/opentagviewer/util/android/PhoneLocation.java new file mode 100644 index 00000000..d6bdf5da --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/android/PhoneLocation.java @@ -0,0 +1,49 @@ +package dev.wander.android.opentagviewer.util.android; + +import androidx.annotation.Nullable; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Where this phone is, for the moment a tag is heard over Bluetooth. + * + *

A seam, because the real answer needs Google Play services and a granted permission. + * The rule that uses it - is this position worth writing down, and what accuracy does it claim - + * is ordinary logic that should not need a device to exercise. + * + *

Null is an ordinary answer, not a failure: location may be off, the permission may not have + * been granted, or the phone may simply have no fix yet. A sighting then records what it always + * did (battery, alignment) and no position, which is the honest outcome. + */ +public interface PhoneLocation { + + /** + * The last position the system already has, or null. + * + *

Deliberately the cached fix rather than a fresh one. Asking for a new fix per + * sighting would be the most expensive thing on a passive scan path, and the accuracy it + * buys is far below what the claim needs: hearing the tag at all already places it within + * Bluetooth range, so a fix good to a few metres is not the limiting factor. + * + *

Blocking. Called from the sighting path, which runs on an Rx io thread. + */ + @Nullable + Fix lastKnown(); + + /** A position with the accuracy the system claims for it, in metres. */ + @AllArgsConstructor + @Getter + final class Fix { + private final double latitude; + private final double longitude; + + /** + * Radius in metres the system claims for this position. + * + *

Written straight into a report's {@code horizontal_accuracy}, which is the same + * field Apple's reports carry, so the two are comparable on the same scale. + */ + private final long accuracyMetres; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AccessoryAlignment.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AccessoryAlignment.java new file mode 100644 index 00000000..e8c0039b --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AccessoryAlignment.java @@ -0,0 +1,102 @@ +package dev.wander.android.opentagviewer.util.parse; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.time.Instant; +import java.time.OffsetDateTime; + +/** + * Where a tag's rolling key search will actually start, read out of its live accessory state. + * + *

This is the value {@link KeyAlignmentPlist} is a stand-in for, and it moves. The plist + * is the {@code KeyAlignmentRecord} the export was made with: written once, at import, and never + * touched again. FindMy.py's serialised accessory is the state the app hands back to Python on + * every fetch and stores again afterwards - see {@code OwnedBeaconDao#updateAccessoryJson} - so + * its {@code alignment_date} is where the next search begins. + * + *

The two disagree from the first successful fetch onwards, and the disagreement grows. A tag + * exported three weeks ago and fetched hourly ever since has a three-week-old plist and an + * alignment date from this morning. + * + *

Jackson rather than {@code org.json}. {@code org.json} ships inside {@code android.jar}, + * where the JVM test runtime stubs it and every getter answers a default - so a test would read + * zero from a document that says otherwise and pass. Jackson is a real dependency on both, which + * is what lets the whole of this be a JVM test. See AGENTS.md rule 13. + */ +public final class AccessoryAlignment { + + private static final String TAG = AccessoryAlignment.class.getSimpleName(); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private AccessoryAlignment() { + } + + /** + * When this accessory's keys were last aligned, from the state the last fetch left behind. + * + * @param accessoryJson {@code OwnedBeacon.accessoryJson}, or null if the row has none. + * @return milliseconds since the epoch, or null if there is no usable date in it. Null is + * ordinary: a tag imported and never fetched has no alignment date yet, and rows + * predating the FindMy 0.9.x upgrade have no accessory JSON at all. + */ + @Nullable + public static Long alignedAtMillis(@Nullable final String accessoryJson) { + final JsonNode value = read(accessoryJson, "alignment_date"); + if (value == null || !value.isTextual()) { + return null; + } + + try { + // FindMy.py writes datetime.isoformat(), which carries an offset. Instant.parse + // wants a 'Z', so this goes through OffsetDateTime and accepts either. + return OffsetDateTime.parse(value.asText().trim()).toInstant().toEpochMilli(); + } catch (final Exception notADate) { + try { + return Instant.parse(value.asText().trim()).toEpochMilli(); + } catch (final Exception stillNotADate) { + Log.w(TAG, "An accessory carried an alignment_date this cannot read", stillNotADate); + return null; + } + } + } + + /** + * The rolling key index the last fetch reached, for the debug panel. + * + *

Shown rather than used: it is the number a bug report about a slow or empty fetch wants + * quoted, because it says how far the search had got and therefore how far the next one has + * to go. Keys step every fifteen minutes, so the index is roughly ninety-six per day since + * pairing. + * + * @return the index, or null if the accessory has never been aligned. + */ + @Nullable + public static Integer alignedIndex(@Nullable final String accessoryJson) { + final JsonNode value = read(accessoryJson, "alignment_index"); + return value == null || !value.isNumber() ? null : value.asInt(); + } + + @Nullable + private static JsonNode read(@Nullable final String accessoryJson, final String field) { + if (accessoryJson == null || accessoryJson.isBlank()) { + return null; + } + + try { + final JsonNode node = MAPPER.readTree(accessoryJson).get(field); + return node == null || node.isNull() ? null : node; + } catch (final Exception unreadable) { + // Deliberately broad, and for the same reason KeyAlignmentPlist is: this runs to + // decide whether to show a banner and to fill a debug row. Neither is worth failing + // a fetch over, and "unknown" is a perfectly good answer. + Log.w(TAG, "Could not read " + field + " out of an accessory", unreadable); + return null; + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BeaconDataParser.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BeaconDataParser.java index 9c4e3802..f3abdd98 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BeaconDataParser.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BeaconDataParser.java @@ -181,7 +181,8 @@ public static List parse(final List rawBeaconData .ignoredAt(beaconData.getOwnedBeaconInfo().ignoredAt) .fruitlessScans(beaconData.getOwnedBeaconInfo().fruitlessScans) .lastScanAt(beaconData.getOwnedBeaconInfo().lastScanAt) - .ownedBeaconPlistRaw(ownedBeaconPList); + .ownedBeaconPlistRaw(ownedBeaconPList) + .ownedBeaconAccessoryJson(beaconData.getOwnedBeaconInfo().accessoryJson); if (userOverrides != null) { // configure user overrides too diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetch.java b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetch.java new file mode 100644 index 00000000..63069f81 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetch.java @@ -0,0 +1,112 @@ +package dev.wander.android.opentagviewer.util.rx; + +import java.util.Collection; +import java.util.concurrent.TimeUnit; + +/** + * Whether a batch of tags is one that will take minutes rather than seconds. + * + *

The banner was showing for fetches that finish immediately. "Still locating your + * tags (2 of 3)" is worth saying when the wait is genuinely long, and is noise otherwise - and a + * message that appears when nothing is wrong is one people learn to ignore before the day it + * matters. It went up for every fetch that passed six seconds, which on a slow network is most + * of them. + * + *

What actually makes a fetch long is the key search, and that is knowable in advance. + * An accessory is located by deriving the rotating keys it would have published and asking + * Apple's network about them. Where the search starts is set by the {@code KeyAlignmentRecord} + * in the export: with a recent one the app resumes near where the tag is now, and with none at + * all {@code FindMyAccessory} starts at index 0 from the pairing date and searches the tag's + * whole life - see AGENTS.md rule 6, which is about the same records and why both paths have to + * keep working. + * + *

Keys advance every fifteen minutes, so 96 a day, and Apple takes roughly 290 of them per + * request. The arithmetic that follows is the whole of this class: + * + * + * + * + * + * + * + *
Alignment last observedKeys to searchRequests
yesterday~961
a week ago~6723
a month ago~2,88010
never (18-month-old tag)~52,000~180
+ * + *

The threshold sits at a week, where the search is still a couple of requests and finishes + * while somebody is looking at the screen. It errs towards showing the banner: being told to + * wait for something that turns out to be quick costs a moment's attention, and being told + * nothing during three minutes of apparent hang is what this whole mechanism exists to prevent. + * + *

Which timestamp is the live one matters, and originally the wrong one was read. The + * record in the export is written once at import; the accessory state Python returns after every + * fetch carries an {@code alignment_date} that moves. See {@link #laterOf}. + * + *

Pure and on the JVM, per AGENTS.md rule 13 - it takes timestamps and returns a boolean. + */ +public final class SlowFirstFetch { + + /** + * How stale an alignment record has to be before its fetch is worth warning about. + * + *

Seven days is about three requests. Below that the search is over before the banner's + * six-second delay has elapsed, so showing it would only ever be a flash. + */ + static final long STALE_AFTER_MS = TimeUnit.DAYS.toMillis(7); + + private SlowFirstFetch() { + } + + /** + * @param alignmentObservedAt when each tag in the batch last had its keys aligned. A + * {@code null} entry is a tag with no alignment record at all, + * which is the slowest case there is. + * @param now the current time, passed in so this can be tested without a + * clock. + * @return true if any one of them will search far enough back to be worth a banner. One is + * enough: the batch is fetched one accessory at a time, so a single unaligned tag + * holds up everything behind it. + */ + /** + * The later of what the export recorded and what the last fetch left behind. + * + *

The record in the export stops being the answer the moment a fetch succeeds. It is + * written once at import and never again; the accessory state Python hands back carries an + * {@code alignment_date} that advances every time. Reading only the record meant a tag + * exported a month ago and fetched hourly ever since still looked like a month-wide search, + * so the banner went up on every refresh - which is the noise this class was written to stop. + * + *

{@code ScanOrder} already had this right, and its comment says so: the record there is + * "only ever consulted for a tag with no scan history". This is that rule, for this caller. + * + *

The later of the two rather than simply preferring the live value, because a re-import + * can bring a newer record than a stale accessory blob, and neither is wrong to trust. + * + * @param alignedAt {@code alignment_date} from the accessory state, or null. + * @param observedAt {@code lastIndexObservationDate} from the export's record, or null. + * @return the later of the two, or null when neither is known - the slowest case there is. + */ + public static Long laterOf(final Long alignedAt, final Long observedAt) { + if (alignedAt == null) { + return observedAt; + } + if (observedAt == null) { + return alignedAt; + } + return Math.max(alignedAt, observedAt); + } + + public static boolean isLikely(final Collection alignmentObservedAt, final long now) { + if (alignmentObservedAt == null) { + // Nothing known about the batch. Treated as slow, because the alternative is + // silence during the exact case the banner is for. + return true; + } + + for (final Long observedAt : alignmentObservedAt) { + if (observedAt == null || now - observedAt > STALE_AFTER_MS) { + return true; + } + } + + return false; + } +} diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 9ff035ce..cc108f1b 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -1,15 +1,17 @@ from enum import Enum from typing import Any, NamedTuple, cast import json +import os import time import traceback -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from io import BytesIO import base64 import NSKeyedUnArchiver from findmy import FindMyAccessory, MobileMeDelegateError from findmy.accessory import FixedRollingKeyPairAccessory +from findmy.keys import KeyPairType from findmy.reports import ( RemoteAnisetteProvider, AppleAccount, @@ -1017,6 +1019,381 @@ def accessoryFromJson(accessoryJson: str) -> StoredAccessory: return accessoryType.from_json(cast(Any, mapping)) +#: How far either side of the believed alignment to look for the accessory's current key. +# +# **Not zero, which is what a bare call would use.** Without a margin the search starts at the +# alignment index, so an accessory whose true index has drifted *below* where alignment believes +# it is can never be matched - it is simply absent from its own candidate set, with nothing +# raising anywhere. FindMy.py's own `NearbyOfflineFindingDevice.is_from` takes the same +# precaution with the same twelve hours, and its docstring records the failure being observed on +# real hardware advertising a metre from the scanner. +# +# **Forty-eight hours, and the number is derived rather than guessed.** Alignment is written +# from `min(key_to_ind[key])` in the pinned FindMy.py's `reports.py` - deliberately the lowest +# index a matched key could belong to, because underestimating is the safe direction. For a +# secondary key that is an underestimate of real size: `keys_at` offers two secondary keys per +# index (`ind // 96 + 1` and `+ 2`), so one secondary key spans 192 primary indices. A report +# decrypted against a secondary key can therefore leave alignment up to 192 indices - 48 hours - +# below the truth, and stay there until a primary match corrects it. +# +# So the margin has to reach 48 hours or a tag aligned that way is absent from its own candidate +# set. Measured before this was understood: a tag beside the phone at -24 dBm, advertising +# steadily, sat 58 indices above where alignment believed "now" was, and the twelve hours +# FindMy.py's own `is_from` uses reaches only 48 indices. +# +# The margin is what lets such a tag be picked up again at all; `recordAccessorySeen`'s +# secondary-key floor is what pulls alignment back up afterwards, so the full width is only +# needed until the first sighting lands. +# +# Forty-eight hours off a fresh alignment is around 1150 key derivations - 1.15s on desktop +# and several times that under Chaquopy, which is why `recordAccessorySeen` takes an index +# hint rather than re-deriving the window on every sighting. The +# cost is only bounded while the alignment *is* fresh, and this app does produce accessories +# where it is not: enabling "show my own Apple devices" puts a phone in the list, and a phone +# has no rolling-key alignment to be fresh. Measured on one that was switched off, the window +# came to 39636 indices - over a year of keys, derived on a blocking call. See +# `_MAC_CANDIDATE_MAX_INDICES`, which is what stops the whole of that being attempted. +_MAC_CANDIDATE_MARGIN = timedelta(hours=48) + +#: How much of a candidate window is derived when the whole of it is too wide. +# +# **This is a guard against one entry costing every other entry its scan.** Callers ask per +# accessory, in a loop, and the derivation is blocking EC work with no interruption point. If +# one accessory takes minutes, the loop never reaches the ones after it and the scan never +# starts - so every tag stops being seen, with nothing failing anywhere to say why. +# +# The width is set by how *stale* the alignment is, not by whether there is one. Measured on +# desktop CPython, which is several times faster than Chaquopy on a phone: +# +# alignment stale by width derivation +# 1 day 144 0.5 s +# 7 days 720 2.3 s +# 30 days 2,928 9.3 s +# 120 days 11,568 36.6 s +# 400 days 38,448 121.9 s +# +# Linear, about 3.2 ms per index. The 39,636-index case that prompted this was an owner's own +# phone, switched off, pulled in by "show my own Apple devices" - a phone has no rolling-key +# alignment and never gains one. +# +# A thousand is roughly a week of staleness: enough for a tag that has missed a few fetches, +# and a couple of seconds at worst. +# +# **An accessory past it is bounded rather than refused**, deriving the newest N indices rather +# than the whole span. A tag that is advertising right now has been running, so its true index +# tracks the wall clock and sits at the top of the window; the bottom is only reachable by a +# tag that was switched off for months, which is not advertising and so has nothing to match +# anyway. Refusing outright was the earlier answer and was worse: it cost every never-aligned +# tag its BLE matching entirely. +# +# Done here with a second key walk, because `current_mac_addresses` in the pinned FindMy.py +# has no `max_indices` to ask for this. Worth sending upstream so the walk can go. +_MAC_CANDIDATE_MAX_INDICES = 1000 + + +#: What `addressesBetween` reports instead of an index it cannot vouch for. Not None, because +#: the mapping crosses to Java as a plain map and a null value there is indistinguishable from +#: an address that was never derived at all. +_INDEX_UNKNOWN = -1 + + +def candidateWindow(accessoryJson: str): + """The key index range worth scanning for this accessory right now, without deriving it. + + **Cheap on purpose.** `currentMacAddresses` answers the same question and pays for the + answer, which is fine when the addresses are what you want and wasteful when all you need + to know is which part of the range you are missing. Deciding that is what lets a caller + keep what it derived last time and ask only for the rest, and the whole point of keeping + it is not paying this cost again. + + Bounded exactly as `currentMacAddresses` bounds it, so the two never disagree about which + slice is the live one. + + Returns a mapping with `lo` and `hi` inclusive, or None if the accessory cannot be read. + """ + try: + accessory = accessoryFromJson(accessoryJson) + + now = datetime.now(timezone.utc) + top = accessory.get_max_index(now + _MAC_CANDIDATE_MARGIN) + width = _isAlignmentWide( + accessory, now - _MAC_CANDIDATE_MARGIN, now + _MAC_CANDIDATE_MARGIN) + + if width > _MAC_CANDIDATE_MAX_INDICES: + bottom = top - _MAC_CANDIDATE_MAX_INDICES + else: + bottom = accessory.get_min_index(now - _MAC_CANDIDATE_MARGIN) + + return {"lo": max(0, bottom), "hi": top} + except Exception: + print(f"candidateWindow failed: {traceback.format_exc()}") + return None + + +def addressesBetween(accessoryJson: str, lo: int, hi: int): + """The addresses this accessory can advertise at every index from `lo` to `hi` inclusive. + + **The set of addresses never goes out of date, and that is what a stored copy rests on.** + An address is a pure function of the accessory's keys and an index, so an address derived + once is still one this accessory can advertise; only which part of the range is worth + watching moves, and that is `candidateWindow`'s answer rather than this one's. Splitting a + range into pieces and joining the results yields exactly the same set as asking for it + whole, which is what lets a caller widen its search a piece at a time. + + **A secondary key's index is reported as -1 rather than as a number that would be + believed.** `keys_between` de-duplicates, and a secondary key covers 96 consecutive primary + indices, so it comes back at the first index the *call's own* range happens to reach: ask + for 19100..19160 and it is 19100, ask for 19131..19160 and the same address is 19131. That + is an artefact of where the search started, not a fact about the tag, and a caller storing + it would later read it as exact. A primary key occurs at exactly one index and does not + move, so its index is given as it is. + + That distinction is what lets an address kept from an earlier, wider derivation still repair + an alignment months out of step: the sighting arrives with an exact index, and + `recordAccessorySeen` confirms it with three derivations instead of searching a window that, + by definition, does not contain it. + + Deliberately takes the range rather than working it out. A caller widening its search a + piece at a time needs to say which piece, and a function that decided for itself could not + be asked for the piece below the one it would have chosen. + + Returns None on failure, which a caller must tell apart from an empty range. + """ + try: + if hi < lo: + return {} + + accessory = accessoryFromJson(accessoryJson) + + started = time.perf_counter() + derived = { + key.mac_address: (index if key.key_type == KeyPairType.PRIMARY else _INDEX_UNKNOWN) + for index, key in accessory.keys_between(max(0, lo), hi) + } + _reportDerivationCost(hi - max(0, lo) + 1, derived, started) + return derived + except Exception: + print(f"addressesBetween failed: {traceback.format_exc()}") + return None + + +def _reportDerivationCost(width, derived, started): + """Says what deriving a candidate window actually cost, in indices and in seconds. + + This is the one expensive call in the BLE path and the only one whose price scales with + how stale an alignment is, so how far a search can be widened before it stops being + affordable is a question about this number. It was answered with adjectives for a long + time - "several times slower under Chaquopy" - which is not a number anybody can size a + background task with. Printed per index rebuild rather than per sighting, which is rare + enough to be free and often enough to catch a device that is far slower than the desktop. + """ + elapsed = time.perf_counter() - started + count = 0 if derived is None else len(derived) + per_thousand = (elapsed / width * 1000) if width else 0.0 + print(f"Derived {count} candidate address(es) over {width} index/indices " + f"in {elapsed:.2f}s ({per_thousand:.2f}s per 1000)") + + +def currentMacAddresses(accessoryJson: str) -> dict[str, int] | None: + """ + The BLE MAC address(es) this accessory might currently be advertising, each with its index. + + Lets Java recognise an owned accessory's own advertisement in a BLE scan, so it can be + triggered directly (playing a sound) without going through Apple's Find My network - the + same thing Find My itself does when a tag is close enough to reach over Bluetooth. + + Delegates to `RollingKeyPairSource.current_mac_addresses`, added to the pinned FindMy.py + fork alongside this feature: it spans the accessory's `get_min_index`/`get_max_index` + range for *now* rather than a single index, to account for rollover uncertainty since the + last observed alignment. + + **Each address maps to the key index it came from**, so a caller that matches one can hand + it straight to `recordAccessorySeen` - which is what keeps the next call cheap. Returning a + bare list would throw that away. + + Returns None on failure so Java can decide how to recover - a missing or unreadable + accessory is worth telling apart from "no keys", which would be an empty mapping. + + **An accessory whose window is absurdly wide has only its newest slice derived**, and that + is decided here rather than in the caller, because by the time the caller could measure the + answer the work has already been done. See `_MAC_CANDIDATE_MAX_INDICES` for what that + protects, and why the slice is the newest one. + """ + try: + accessory = accessoryFromJson(accessoryJson) + + now = datetime.now(timezone.utc) + started = time.perf_counter() + width = _isAlignmentWide( + accessory, now - _MAC_CANDIDATE_MARGIN, now + _MAC_CANDIDATE_MARGIN) + + if width > _MAC_CANDIDATE_MAX_INDICES: + # **Bounded to the newest slice rather than refused.** A tag advertising right now + # has been running, so its index tracks the wall clock and sits at the top of the + # window; the bottom belongs to a tag that was switched off for months, which is not + # advertising and so has nothing to match anyway. Refusing outright cost every + # never-aligned tag its BLE matching, which is a worse trade than searching the part + # of the range that can plausibly be live. + top = accessory.get_max_index(now + _MAC_CANDIDATE_MARGIN) + bottom = top - _MAC_CANDIDATE_MAX_INDICES + print(f"Candidate window is {width} indices wide; deriving only the newest " + f"{_MAC_CANDIDATE_MAX_INDICES} ({bottom}..{top}), which is what a running " + f"accessory can plausibly be advertising.") + derived = { + key.mac_address: index + for index, key in accessory.keys_between(max(0, bottom), top) + } + _reportDerivationCost(_MAC_CANDIDATE_MAX_INDICES, derived, started) + return derived + + derived = accessory.current_mac_addresses(margin=_MAC_CANDIDATE_MARGIN) + _reportDerivationCost(width, derived, started) + return derived + except Exception: + print(f"currentMacAddresses failed: {traceback.format_exc()}") + return None + + +def _matchAt(accessory, mac: str, index: int | None): + """Check one index for `mac`, which is the whole point of the hint. + + **Java knows which index its candidate set derived the address from, and cannot act on + it.** Only here can a primary key be told from a secondary one, and that distinction is what + decides whether alignment may be trusted. So the index arrives as a hint to be verified + rather than as an answer: this re-derives the keys at that index and checks the address + itself, exactly as the wide scan would, and reports the key type it actually found. + + The saving is the reason it exists. The 48-hour window is around 1150 key derivations, + measured at 1.15s on desktop and several times that under Chaquopy; one index is three. + Running the wide version on the sighting callback's cadence put the app at 135% CPU with two + tags in range and got it killed for not answering input. + """ + if index is None: + return None, None + + for key in accessory.keys_at(index): + if key.mac_address != mac: + continue + if key.key_type == KeyPairType.PRIMARY: + return index, None + return None, index + + return None, None + + +def _matchAcross(candidates: dict, mac: str): + """Find `mac` among already-derived keys, preferring a primary match.""" + matched_secondary = None + + for key, index in candidates.items(): + if key.mac_address != mac: + continue + if key.key_type == KeyPairType.PRIMARY: + return index, None + matched_secondary = index + + return None, matched_secondary + + +def recordAccessorySeen(accessoryJson: str, mac: str, seenAtUnixMs: int, + hintIndex: int | None = None) -> str | None: + """ + Tell an accessory it was seen advertising as `mac`, and hand back its new state. + + **This is what stops the margin above being paid for twice.** A BLE sighting is worth + realigning to, the same as a decrypted location report is. Without this the twelve-hour + range is re-derived on every scan; with it, the call after a hit collapses to the three + keys of a single index. + + **Takes the address rather than the index `currentMacAddresses` returned for it, and that + difference is load-bearing.** That index is only trustworthy when the address came from a + *primary* key: a primary index is unique, one key per index, so a match against it proves + the true index outright. A secondary key covers 96 consecutive primary indices - see + `_AccessoryKeyGenerator._secondary_keys_at` - so its index is only the first one the search + happened to reach, not the true one. Fed to `update_alignment` without checking, that index + can ratchet alignment past the true index in the wrong direction - measured on a real + accessory that drifted 114 indices (28.5 hours) ahead this way and then needed a multi-day + margin just to be found at all. The fix has to happen here rather than by filtering the map + `currentMacAddresses` returns, because an address derived from a secondary key is still + worth *scanning for* - only not worth *aligning to*. + + **Does not go through `update_alignment`, and that is also deliberate.** It only ever moves + forward - correct for a fetch, where every index it sees came from searching ahead of where + alignment already believes it is, so "never seen a lower one" is a safe rule there. A BLE + match is not built that way: it comes from a wide, symmetric window, so a primary match can + legitimately land below the stored alignment - proof that alignment had already drifted too + far ahead, from an earlier secondary-key mistake or otherwise. Refusing to correct downward + would leave that drift permanent, which is the whole failure this function exists to undo. + So a primary match's index is written to the accessory's serialized state directly, in + either direction. + + So this re-derives the key at `mac` itself, from scratch, and only accepts a match through + its primary key. A secondary-only match, or no match at all (the candidate set may have + moved on since the scan that found `mac`), records nothing. + + Returns the re-serialized accessory for Java to write back to `OwnedBeacon.accessory_json`, + the same field and the same reason as `getLastReports`' `updatedAccessoryJson`. None on + failure or on nothing worth recording, because a sighting that cannot be recorded is not + worth failing a sound over. + """ + try: + accessory = accessoryFromJson(accessoryJson) + if not isinstance(accessory, FindMyAccessory): + # A self-generated accessory's keys don't rotate (update_alignment is a no-op for + # it) - there is no drift here for this to fix. + return None + + seen_at = datetime.fromtimestamp(seenAtUnixMs / 1000, tz=timezone.utc) + mac = mac.upper() + + matched_primary, matched_secondary = _matchAt(accessory, mac, hintIndex) + + if matched_primary is None and matched_secondary is None: + # The hint missed, or there was none. Fall back to the window the caller's candidate + # set was built from - the address may belong to an index the hint did not name, and + # a scan that found it must not be thrown away over a wrong guess. + matched_primary, matched_secondary = _matchAcross( + accessory.current_keys(seen_at, margin=_MAC_CANDIDATE_MARGIN), mac) + + mapping = json.loads(accessoryJson) + stored_index = mapping.get("alignment_index") + + if matched_primary is not None: + matched_index = matched_primary + elif matched_secondary is not None and ( + stored_index is None or matched_secondary > stored_index): + # A floor, not a fix. The true index is somewhere in this secondary key's 192-index + # span - `keys_at` offers each secondary at both `ind // 96 + 1` and `+ 2`, so one is + # reachable from two 96-blocks, which is the same 192 the margin above is derived + # from - and `keys_between` de-duplicates while walking indices upward, so the index + # paired with a key is the lowest in the searched range at which it is valid. It is + # therefore a lower bound, and moving alignment up to it can only ever undershoot + # the truth - never overshoot it, which is the direction that does damage. Raising + # the floor is what stops the lag growing without bound for a tag that only ever + # matches on its day key. + matched_index = matched_secondary + else: + return None + + if stored_index == matched_index: + return None + + # Bypassing update_alignment for the downward index move must not also bypass its + # backward-time guard: a device clock rolled back (manual change, bad carrier time) + # would otherwise persist a (past date, current index) pair, and once the clock + # corrects, the index extrapolated from that past date overshoots the true one. + stored_date = mapping.get("alignment_date") + if stored_date is not None and seen_at < datetime.fromisoformat(stored_date): + return None + + mapping["alignment_index"] = matched_index + mapping["alignment_date"] = seen_at.isoformat() + return json.dumps(mapping) + except Exception: + print(f"recordAccessorySeen failed: {traceback.format_exc()}") + return None + + def _isAlignmentWide(accessory: StoredAccessory, start, end) -> int: """Width of the key-index range a history fetch would search, or 0 if unknown.""" try: @@ -1257,6 +1634,109 @@ def _updateAlignment(accessory: StoredAccessory, report, index): print(f"Could not update alignment: {traceback.format_exc()}") +def _alignmentOf(accessory: StoredAccessory): + """The stored (index, date) pair, or (None, None) for an accessory that has no alignment.""" + try: + mapping = accessory.to_json() + return mapping.get("alignment_index"), mapping.get("alignment_date") + except Exception: + return None, None + + +def _reportDrift(before_index, before_date, after_index, after_date) -> None: + """Says how far the extrapolation had run from where the tag turned out to be. + + **The one measurement that settles how wide a search has to be.** Everything about which + addresses are worth scanning for rests on extrapolating the stored alignment forward at one + index every fifteen minutes, and on that extrapolation staying close to where the tag really + is. Nobody has ever measured whether it does. The candidate window, the bounded slice, how + far back a search should reach - all of it is currently sized by argument rather than by a + number. + + An alignment that moved during a fetch is a real observation: something decrypted, so the new + pair says where the tag actually was at a moment. Extrapolating the *old* pair forward to that + same moment and subtracting gives the drift, as a signed number of indices, for free, on every + fetch that finds anything. + + Positive means the extrapolation had run ahead of the tag, which is the direction that loses + it: the search then looks above where the tag is. Around zero over weeks would mean a tag that + is merely out of contact stays where the extrapolation says, and a search that widens downward + is solving a problem nobody has. + + Compared this way rather than from a report's own index because the ordinary fetch never hands + one over: `fetch_location_history(accessory)` updates the alignment inside FindMy.py, so the + only place a report's index is visible in this file is the ranged path, which is the rarer + half. The before-and-after pair is visible in both. + """ + if None in (before_index, before_date, after_index, after_date): + return + + if before_index == after_index and before_date == after_date: + # The fetch found nothing to align to. Not a drift of zero, which is why it is not + # reported as one: a series full of those would read as a stable extrapolation. + return + + try: + moved_by = datetime.fromisoformat(after_date) - datetime.fromisoformat(before_date) + extrapolated = before_index + int(moved_by // timedelta(minutes=15)) + except Exception: + return + + index = after_index + + line = (f"Alignment drift: report at index {index}, extrapolated {extrapolated}, " + f"drift {extrapolated - index} index/indices " + f"({(extrapolated - index) / 4:.1f} hours ahead)") + + print(line) + _appendDiagnostic(line) + + +#: Where diagnostics are appended, set once by Java. None means logcat only. +_DIAGNOSTICS_PATH = None + +#: Past this the file is halved, oldest first. A drift line is about 110 bytes, so this keeps +#: something like the last thousand readings - months of fetches, and still nothing to notice. +_DIAGNOSTICS_MAX_BYTES = 128 * 1024 + + +def setDiagnosticsPath(path: str) -> None: + """Point diagnostics at a file, so a measurement outlives the logcat ring buffer. + + **Because the alternative was asking somebody to leave a phone plugged in.** The drift + measurement is only worth anything as a series over weeks, and logcat on a busy device + holds minutes. Java passes a directory it can reach without root - its own external files + directory - so the file can be pulled whenever the phone next happens to be connected. + """ + global _DIAGNOSTICS_PATH + _DIAGNOSTICS_PATH = os.path.join(path, "diagnostics.log") if path else None + + +def _appendDiagnostic(line: str) -> None: + """Adds one timestamped line, halving the file if it has grown past the cap. + + Never raises. A diagnostic that can break the thing it is measuring is worse than no + diagnostic, and this sits directly in the fetch path. + """ + path = _DIAGNOSTICS_PATH + if not path: + return + + try: + stamped = f"{datetime.now(timezone.utc).isoformat(timespec='seconds')} {line}\n" + + if os.path.exists(path) and os.path.getsize(path) > _DIAGNOSTICS_MAX_BYTES: + with open(path, "r", encoding="utf-8", errors="replace") as existing: + kept = existing.readlines() + with open(path, "w", encoding="utf-8") as trimmed: + trimmed.writelines(kept[len(kept) // 2:]) + + with open(path, "a", encoding="utf-8") as out: + out.write(stamped) + except Exception: + print(f"Could not write a diagnostic line: {traceback.format_exc()}") + + def _serializeReports(reports): """ Map FindMy 0.9.x LocationReport objects to the dict shape Java's mapResults expects. @@ -1343,6 +1823,7 @@ def getLastReports( # Measured before and after, because "found nothing" on its own says nothing. # See _DEAD_TAG_WIDTH_INDICES. width_before = _isAlignmentWide(airtag, start_dt, now_dt) + aligned_before = _alignmentOf(airtag) # Per-accessory isolation. One beacon failing used to abort the whole call, # which meant no beacon's updated alignment was persisted - so every later @@ -1358,6 +1839,12 @@ def getLastReports( print(f"Got {len(reports)} raw reports for {beaconId}") + # Measured here because this is the one place both halves are in scope: what the + # alignment said before anything was fetched, and what the fetch made of it. + aligned_after = _alignmentOf(airtag) + _reportDrift(aligned_before[0], aligned_before[1], + aligned_after[0], aligned_after[1]) + # A search that stayed as wide as it started found nothing to align to, which is # the difference between "no reports in the window asked for" and "no reports at # all, anywhere in this tag's life". diff --git a/app/src/main/res/layout/activity_device_info.xml b/app/src/main/res/layout/activity_device_info.xml index 8644886e..b1eb0a2b 100644 --- a/app/src/main/res/layout/activity_device_info.xml +++ b/app/src/main/res/layout/activity_device_info.xml @@ -102,6 +102,45 @@ name="batteryLevel" type="String" /> + + + + + + + + + + + + + + + + @@ -385,6 +424,7 @@ app:sectionSubtitle="@{deviceType}" app:title="@{@string/type}" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -299,6 +300,125 @@ android:textSize="13sp" android:onClick="@{() -> onClickAppleDevicesHelpLink.run()}" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + - + android:orientation="horizontal" + android:gravity="center_vertical"> + + + + + + @@ -267,6 +293,8 @@ android:id="@+id/device_ring_button_container" android:layout_width="0dp" android:layout_height="wrap_content" + android:layout_marginLeft="8dp" + android:layout_marginRight="8dp" android:layout_weight="1" android:background="@drawable/ripple_rounded_rect" android:clickable="true" @@ -274,7 +302,7 @@ android:onClick="onClickRing" android:orientation="vertical" android:padding="5dp" - android:visibility="gone"> + android:visibility="visible"> + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index eb846b7f..60a07424 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -310,6 +310,59 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Auf dem Stand deines Apple-Kontos Dein Apple-Konto war gerade nicht erreichbar. An deinen Tags hat sich nichts geändert. Dieses Protokoll ist zu groß zum Kopieren. Speichere es stattdessen als Datei und hänge diese an. + In der Nähe klingeln lassen + Suche in der Nähe per Bluetooth… + Ton wird jetzt abgespielt. + Nicht in der Nähe gefunden. Komm näher und versuche es erneut. + Verbunden, aber dieses Zubehör hat keinen Ton-Dienst. + Die aktuelle Adresse dieses Zubehörs konnte nicht berechnet werden. + Für das Klingeln in der Nähe wird die Bluetooth-Berechtigung benötigt. + Ton konnte nicht abgespielt werden. Versuche es erneut. + Stopp + Gefunden, verbinde… + Sende Ton-Befehl… + Suche… + Verbinde… + Sende… + Klingelt! + In der Nähe + In der Nähe · Akku %1$s + In der Nähe (%2$s) · Akku %1$s + Gerade per Bluetooth vom Tag selbst gelesen, nicht aus iCloud. + voll + mittel + niedrig + kritisch + Akku + Über Bluetooth + Zuletzt gehört + Signal + gerade eben + Im Hintergrund weiter empfangen + Empfängt deine Tags auch bei geschlossener App und hält fest, wo sie gehört wurden. Braucht eine dauerhafte Benachrichtigung und mehr Akku. Standardmäßig aus. + Hintergrund-Empfang + Wird angezeigt, solange die App im Hintergrund auf deine Tags hört. + Empfängt deine Tags + Zum Öffnen der Karte tippen. + Zurückgelassen + Meldet sich, wenn ein Tag nicht mehr zu hören ist und du dich davon entfernt hast. + %1$s ist zurückgeblieben + Zuletzt dort gehört, wo du %1$s warst. Zum Anzeigen tippen. + Warnen, wenn zurückgelassen + Schlägt Alarm, wenn dieses Tag nicht mehr zu hören ist und du weitergegangen bist. Setzt den Hintergrund-Empfang voraus. + Nach %1$d Sekunden warnen + Wie lange ein Tag nicht zu hören sein muss, bevor die App prüft, ob du es zurückgelassen hast. Kürzer warnt früher und prüft öfter. + Alarmton + Standard-Alarmton + Apple-Konto erneut verbinden + Diese App hat eine Verbindung zu deinem Apple-Konto gespeichert, kann sie aber nicht mehr lesen. Die dafür nötigen Schlüssel liegen im sicheren Speicher dieses Geräts und lassen sich nicht wiederherstellen – die Verbindung muss also neu hergestellt werden. + +Deine Tags und ihr Standortverlauf sind davon nicht betroffen, und aus deinem Apple-Konto wurde nichts entfernt. + Die gespeicherte Verbindung zum Apple-Konto ließ sich nicht entschlüsseln, obwohl ihr Schlüssel noch vorhanden ist + Die Verbindung zu deinem Apple-Konto liegt auf diesem Gerät, der Schlüssel dazu ist noch da, und sie lässt sich trotzdem nicht mehr öffnen – das sollte nicht möglich sein.\n\nDeshalb lohnt sich eine Meldung, statt es einfach neu einzurichten. Deine Tags und ihr Standortverlauf sind nicht betroffen, und aus deinem Apple-Konto wurde nichts entfernt. + Schlüssel-Ausrichtung + Index %1$d, ausgerichtet %2$s + Keine gespeichert — der nächste Abruf sucht ab dem Kopplungsdatum Apple hat deinen Code angenommen und danach die Anmeldung nicht abschließen können. Der Code ist damit verbraucht, es wird also ein neuer gebraucht – wir warten kurz, bevor wir ihn anfordern, denn sofortiges Anfordern wird abgelehnt. Neuer Code wird in %1$d s bei Apple angefordert … Apple schließt die Anmeldung weiterhin nicht ab. Das liegt an Apple, nicht an dir und nicht an deinem Code.\n\nVersuche es in ein paar Minuten erneut. Dein Passwort wird dabei möglicherweise einmal abgelehnt – das gehört zum selben Fehler, gib es also einfach noch einmal ein, statt es für falsch zu halten. diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 7672e7b4..2ab0b915 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -310,6 +310,59 @@ You can set this up now, or any time later from Settings. Up to date with your Apple account Could not reach your Apple account just now. Your tags are unchanged. This log is too large to copy. Save it as a file and attach that instead. + Play Sound Nearby + Searching nearby over Bluetooth… + Playing sound now. + Not found nearby. Move closer and try again. + Connected, but this accessory has no sound service. + Could not compute this accessory\'s current address. + Bluetooth permission is needed to play the sound nearby. + Could not play the sound. Try again. + Stop + Found nearby, connecting… + Sending sound command… + Scanning… + Connecting… + Sending… + Ringing! + Nearby + Nearby · Battery %1$s + Read from the tag over Bluetooth just now, not from iCloud. + full + medium + low + critical + Battery + Nearby (%2$s) · Battery %1$s + Over Bluetooth + Last seen + Signal + just now + Keep listening in the background + Listens for your tags even when the app is closed, and records where they were heard. Needs a permanent notification, and uses more battery. Off by default. + Background scanning + Shown while the app is listening for your tags in the background. + Listening for your tags + Tap to open the map. + Left behind + Alerts you when a tag stops being heard and you have moved away from it. + %1$s stayed behind + Last heard where you were %1$s. Tap to see the place. + Warn if left behind + Sounds an alarm when this tag stops being heard and you have moved on. Needs background listening to be on. + Warn after %1$d seconds + How long a tag has to go unheard before the app checks whether you have left it behind. Shorter catches you sooner and checks more often. + Alarm sound + Default alarm sound + Reconnect your Apple account + This app has a connection to your Apple account saved, but can no longer read it. The keys it needs are kept in this device\'s secure storage, and they cannot be recovered — so the connection has to be made again. + +Your tags and their location history are not affected, and nothing was removed from your Apple account. + The saved Apple account connection could not be decrypted, although its key is still present + The connection to your Apple account is stored on this device, the key that unlocks it is still here, and it no longer opens — which should not be possible.\n\nThat makes it worth reporting rather than just redoing. Your tags and their location history are not affected, and nothing was removed from your Apple account. + Key alignment + Index %1$d, aligned %2$s + None stored — the next fetch searches from the pairing date Apple accepted your code and then had a problem finishing the sign-in. The code is used up, so a new one is needed — waiting a moment before asking for it, because asking straight away is refused. Asking Apple for a new code in %1$d s… Apple is still not finishing the sign-in. This is a fault on Apple\'s side, not something you did, and not your code.\n\nTry again in a few minutes. Your password may be refused once when you do — that is part of the same fault, so enter it again rather than assuming it is wrong. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c09e863a..0216dd15 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -310,6 +310,59 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.< À jour avec votre compte Apple Impossible de joindre votre compte Apple pour le moment. Vos tags sont inchangés. Ce journal est trop volumineux pour être copié. Enregistrez-le plutôt sous forme de fichier et joignez celui-ci. + Faire sonner à proximité + Recherche à proximité via Bluetooth… + Lecture du son en cours. + Introuvable à proximité. Rapprochez-vous et réessayez. + Connecté, mais cet accessoire n\'a pas de service sonore. + Impossible de calculer l\'adresse actuelle de cet accessoire. + L\'autorisation Bluetooth est nécessaire pour faire sonner l\'appareil à proximité. + Impossible de jouer le son. Réessayez. + Arrêter + Trouvé à proximité, connexion… + Envoi de la commande sonore… + Recherche… + Connexion… + Envoi… + Sonne ! + À proximité + À proximité · Batterie %1$s + Lu à l\'instant depuis le tag via Bluetooth, pas depuis iCloud. + pleine + moyenne + faible + critique + Batterie + À proximité (%2$s) · Batterie %1$s + Par Bluetooth + Dernière détection + Signal + à l\'instant + Continuer l\'écoute en arrière-plan + Écoute vos balises même quand l\'application est fermée et note où elles ont été entendues. Nécessite une notification permanente et consomme plus de batterie. Désactivé par défaut. + Écoute en arrière-plan + Affichée tant que l\'application écoute vos balises en arrière-plan. + Écoute de vos balises + Touchez pour ouvrir la carte. + Oublié + Vous alerte quand une balise n\'est plus entendue et que vous vous en êtes éloigné. + %1$s est resté sur place + Entendue pour la dernière fois là où vous étiez %1$s. Touchez pour voir l\'endroit. + Avertir si oublié + Déclenche une alarme quand cette balise n\'est plus entendue et que vous êtes parti. Nécessite l\'écoute en arrière-plan. + Avertir après %1$d secondes + Durée pendant laquelle un tag doit rester inaudible avant que l\'application vérifie si vous l\'avez oublié. Plus court avertit plus tôt et vérifie plus souvent. + Son de l\'alarme + Son d\'alarme par défaut + Reconnectez votre compte Apple + Cette application a une connexion à votre compte Apple enregistrée, mais ne parvient plus à la lire. Les clés nécessaires sont conservées dans le stockage sécurisé de cet appareil et ne peuvent pas être récupérées : la connexion doit donc être refaite. + +Vos tags et leur historique de position ne sont pas touchés, et rien n’a été supprimé de votre compte Apple. + La connexion au compte Apple enregistrée n’a pas pu être déchiffrée, bien que sa clé soit toujours présente + La connexion à votre compte Apple est enregistrée sur cet appareil, la clé qui l’ouvre est toujours là, et elle ne s’ouvre plus : cela ne devrait pas être possible.\n\nCela vaut donc la peine d’être signalé plutôt que simplement refait. Vos tags et leur historique de position ne sont pas touchés, et rien n’a été supprimé de votre compte Apple. + Alignement des clés + Index %1$d, aligné %2$s + Aucun enregistré — la prochaine récupération part de la date d\'association Apple a accepté votre code puis n’a pas pu terminer la connexion. Le code est donc utilisé et il en faut un nouveau — nous patientons un instant avant de le demander, car une demande immédiate est refusée. Nouveau code demandé à Apple dans %1$d s… Apple ne termine toujours pas la connexion. C’est une panne du côté d’Apple, pas quelque chose que vous avez fait, ni votre code.\n\nRéessayez dans quelques minutes. Votre mot de passe pourra être refusé une fois à ce moment-là : cela fait partie de la même panne, saisissez-le à nouveau plutôt que de le croire erroné. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 652a6804..01a38782 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -310,6 +310,59 @@ Apple アカウントと同じ状態になりました いま Apple アカウントに接続できませんでした。タグはそのままです。 このログはコピーするには大きすぎます。代わりにファイルとして保存して添付してください。 + 近くで音を鳴らす + Bluetoothで近くを検索中… + 音を再生しています。 + 近くに見つかりませんでした。近づいてもう一度お試しください。 + 接続しましたが、このアクセサリにはサウンド機能がありません。 + このアクセサリの現在のアドレスを計算できませんでした。 + 近くで音を鳴らすにはBluetoothの権限が必要です。 + 音を再生できませんでした。もう一度お試しください。 + 停止 + 近くで見つかりました、接続中… + 音声コマンドを送信中… + 検索中… + 接続中… + 送信中… + 鳴っています! + 近くにあります + 近くにあります · バッテリー%1$s + たった今Bluetoothでタグ本体から読み取りました。iCloudの値ではありません。 + 十分 + + + 危険 + バッテリー + 近くにあります(%2$s)· 電池 %1$s + Bluetooth 経由 + 最後の受信 + 信号強度 + たった今 + バックグラウンドで受信を続ける + アプリを閉じていてもタグを受信し、聞こえた場所を記録します。常時通知が必要で、電池の消費が増えます。既定ではオフです。 + バックグラウンド受信 + アプリがバックグラウンドでタグを受信している間に表示されます。 + タグを受信中 + タップして地図を開きます。 + 置き忘れ + タグが受信できなくなり、その場所から離れたときに知らせます。 + %1$s が置き去りです + %1$sにいた場所で最後に受信しました。タップして場所を表示します。 + 置き忘れたら警告 + このタグが受信できなくなり、その場を離れたときにアラームを鳴らします。バックグラウンド受信が必要です。 + %1$d 秒後に通知 + タグの信号が途絶えてから、置き忘れを確認するまでの時間です。短いほど早く気づき、確認回数も増えます。 + アラーム音 + 既定のアラーム音 + Apple アカウントを接続し直してください + このアプリには Apple アカウントとの接続が保存されていますが、読み取れなくなりました。必要な鍵はこの端末の安全な保管領域にあり、復元できません。そのため接続をやり直す必要があります。 + +タグとその位置履歴には影響がなく、Apple アカウントからは何も削除されていません。 + 保存された Apple アカウント接続を復号できませんでした。鍵は残っています + Apple アカウントへの接続はこの端末に保存されていて、それを開く鍵も残っているのに、開けなくなりました。本来ありえないことです。\n\nそのため、設定し直すだけでなく報告する価値があります。タグとその位置履歴には影響がなく、Apple アカウントからは何も削除されていません。 + キーの同期位置 + インデックス %1$d、同期日時 %2$s + 保存されていません — 次回の取得はペアリング日から検索します Apple はコードを受け付けたあと、サインインを完了できませんでした。コードは使用済みなので新しいものが必要です。すぐに要求しても拒否されるため、少し待ってから要求します。 %1$d 秒後に Apple へ新しいコードを要求します… Apple はまだサインインを完了できていません。これは Apple 側の障害であり、あなたの操作やコードのせいではありません。\n\n数分後にもう一度お試しください。そのときパスワードが一度だけ拒否されることがありますが、これも同じ障害の一部です。間違っていると考えず、もう一度入力してください。 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ca9b23ba..522e4e42 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -310,6 +310,59 @@ Apple 계정과 동기화되었습니다 지금은 Apple 계정에 연결하지 못했습니다. 태그는 그대로입니다. 이 로그는 너무 커서 복사할 수 없습니다. 대신 파일로 저장한 뒤 첨부하세요. + 근처에서 소리 재생 + 블루투스로 근처 검색 중… + 소리를 재생하고 있습니다. + 근처에서 찾을 수 없습니다. 더 가까이 가서 다시 시도하세요. + 연결되었지만 이 액세서리에는 사운드 서비스가 없습니다. + 이 액세서리의 현재 주소를 계산할 수 없습니다. + 근처에서 소리를 재생하려면 블루투스 권한이 필요합니다. + 소리를 재생할 수 없습니다. 다시 시도하세요. + 중지 + 근처에서 찾았습니다, 연결 중… + 소리 명령 전송 중… + 검색 중… + 연결 중… + 전송 중… + 울리는 중! + 근처에 있음 + 근처에 있음 · 배터리 %1$s + 방금 블루투스로 태그에서 직접 읽었습니다. iCloud 값이 아닙니다. + 충분 + 보통 + 부족 + 위험 + 배터리 + 근처에 있음 (%2$s) · 배터리 %1$s + 블루투스로 수신 + 마지막 수신 + 신호 세기 + 방금 + 백그라운드에서 계속 수신 + 앱을 닫아도 태그를 수신하고 수신한 위치를 기록합니다. 상시 알림이 필요하며 배터리를 더 사용합니다. 기본값은 꺼짐입니다. + 백그라운드 수신 + 앱이 백그라운드에서 태그를 수신하는 동안 표시됩니다. + 태그 수신 중 + 탭하여 지도를 엽니다. + 두고 옴 + 태그가 더 이상 수신되지 않고 그 자리에서 멀어졌을 때 알립니다. + %1$s을(를) 두고 왔습니다 + %1$s에 있던 곳에서 마지막으로 수신했습니다. 탭하여 위치를 확인하세요. + 두고 오면 경고 + 이 태그가 더 이상 수신되지 않고 자리를 떠났을 때 알람을 울립니다. 백그라운드 수신이 필요합니다. + %1$d초 후 경고 + 태그 신호가 끊긴 뒤 물건을 두고 왔는지 확인하기까지의 시간입니다. 짧을수록 빨리 알아차리고 더 자주 확인합니다. + 알람음 + 기본 알람음 + Apple 계정을 다시 연결하세요 + 이 앱에 Apple 계정 연결이 저장되어 있지만 더 이상 읽을 수 없습니다. 필요한 키는 이 기기의 보안 저장소에 있으며 복구할 수 없으므로 연결을 다시 만들어야 합니다. + +태그와 위치 기록에는 영향이 없으며, Apple 계정에서 삭제된 것도 없습니다. + 저장된 Apple 계정 연결을 복호화하지 못했습니다. 키는 그대로 남아 있습니다 + Apple 계정 연결은 이 기기에 저장되어 있고 이를 여는 키도 그대로 있는데 더 이상 열리지 않습니다. 원래는 있을 수 없는 일입니다.\n\n그래서 그냥 다시 설정하기보다 신고할 가치가 있습니다. 태그와 위치 기록에는 영향이 없으며, Apple 계정에서 삭제된 것도 없습니다. + 키 정렬 + 인덱스 %1$d, 정렬 시각 %2$s + 저장된 값 없음 — 다음 가져오기는 페어링 날짜부터 검색합니다 Apple이 코드를 받은 뒤 로그인을 끝내지 못했습니다. 코드는 이미 사용되었으므로 새 코드가 필요합니다. 바로 요청하면 거부되기 때문에 잠시 기다린 뒤 요청합니다. %1$d초 후에 Apple에 새 코드를 요청합니다… Apple이 아직 로그인을 마치지 못하고 있습니다. 이는 Apple 쪽 장애이며, 사용자의 잘못도 코드 문제도 아닙니다.\n\n몇 분 뒤에 다시 시도하세요. 그때 비밀번호가 한 번 거부될 수 있는데, 이것도 같은 장애의 일부이므로 틀렸다고 생각하지 말고 다시 입력하세요. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 3139a056..fb2f4196 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -310,6 +310,59 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen. Bijgewerkt met je Apple-account Je Apple-account was even niet bereikbaar. Je tags zijn ongewijzigd. Dit logbestand is te groot om te kopiëren. Sla het op als bestand en voeg dat toe. + In de buurt laten piepen + Zoeken in de buurt via Bluetooth… + Geluid wordt nu afgespeeld. + Niet in de buurt gevonden. Kom dichterbij en probeer het opnieuw. + Verbonden, maar dit accessoire heeft geen geluidsservice. + Kan het huidige adres van dit accessoire niet berekenen. + Bluetooth-toestemming is nodig om het geluid in de buurt af te spelen. + Kan het geluid niet afspelen. Probeer het opnieuw. + Stoppen + Gevonden, verbinden… + Geluidscommando versturen… + Zoeken… + Verbinden… + Versturen… + Rinkelt! + In de buurt + In de buurt · Batterij %1$s + Zojuist via Bluetooth van de tag zelf gelezen, niet uit iCloud. + vol + half + laag + kritiek + Batterij + In de buurt (%2$s) · Batterij %1$s + Via bluetooth + Laatst gehoord + Signaal + zojuist + Op de achtergrond blijven luisteren + Luistert naar je tags ook als de app dicht is en legt vast waar ze gehoord zijn. Vereist een permanente melding en kost meer batterij. Standaard uit. + Achtergrondscan + Wordt getoond zolang de app op de achtergrond naar je tags luistert. + Luistert naar je tags + Tik om de kaart te openen. + Achtergelaten + Waarschuwt je als een tag niet meer te horen is en je ervandaan bent gelopen. + %1$s is achtergebleven + Laatst gehoord waar je %1$s was. Tik om de plek te zien. + Waarschuwen bij achterlaten + Slaat alarm als deze tag niet meer te horen is en je verder bent gelopen. Vereist luisteren op de achtergrond. + Waarschuwen na %1$d seconden + Hoe lang een tag onhoorbaar moet blijven voordat de app controleert of je hem hebt laten liggen. Korter waarschuwt eerder en controleert vaker. + Alarmgeluid + Standaard alarmgeluid + Verbind je Apple-account opnieuw + Deze app heeft een verbinding met je Apple-account opgeslagen, maar kan die niet meer lezen. De benodigde sleutels staan in de beveiligde opslag van dit apparaat en zijn niet te herstellen — de verbinding moet dus opnieuw worden gemaakt. + +Je tags en hun locatiegeschiedenis blijven ongemoeid, en er is niets uit je Apple-account verwijderd. + De opgeslagen verbinding met het Apple-account kon niet worden ontsleuteld, terwijl de sleutel er nog wel is + De verbinding met je Apple-account staat op dit apparaat, de sleutel die hem opent is er nog, en toch gaat hij niet meer open — dat hoort niet te kunnen.\n\nDaarom is dit het melden waard in plaats van het gewoon opnieuw te doen. Je tags en hun locatiegeschiedenis blijven ongemoeid, en er is niets uit je Apple-account verwijderd. + Sleuteluitlijning + Index %1$d, uitgelijnd %2$s + Niets opgeslagen — de volgende ophaalactie zoekt vanaf de koppeldatum Apple heeft je code geaccepteerd en kon daarna het inloggen niet afronden. De code is dus opgebruikt en er is een nieuwe nodig — we wachten even voordat we die aanvragen, want meteen aanvragen wordt geweigerd. Over %1$d s wordt een nieuwe code bij Apple aangevraagd… Apple rondt het inloggen nog steeds niet af. Dit ligt aan Apple, niet aan jou en niet aan je code.\n\nProbeer het over een paar minuten opnieuw. Je wachtwoord kan dan één keer worden geweigerd — dat hoort bij dezelfde storing, dus voer het gewoon nog een keer in in plaats van aan te nemen dat het fout is. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6b0fd930..392b4e31 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -310,6 +310,59 @@ Данные соответствуют учётной записи Apple Сейчас не удалось связаться с учётной записью Apple. Метки не изменились. Этот журнал слишком велик для копирования. Сохраните его в файл и приложите его. + Издать звук поблизости + Поиск поблизости через Bluetooth… + Воспроизведение звука… + Не найдено поблизости. Подойдите ближе и попробуйте снова. + Подключено, но у этого аксессуара нет звуковой службы. + Не удалось вычислить текущий адрес этого аксессуара. + Для воспроизведения звука поблизости требуется разрешение Bluetooth. + Не удалось воспроизвести звук. Попробуйте снова. + Стоп + Найдено поблизости, подключение… + Отправка команды звука… + Поиск… + Подключение… + Отправка… + Звонит! + Рядом + Рядом · Батарея %1$s + Только что считано с метки по Bluetooth, а не из iCloud. + полный + средний + низкий + критический + Батарея + Рядом (%2$s) · Батарея %1$s + По Bluetooth + Последний сигнал + Сигнал + только что + Продолжать приём в фоне + Принимает сигналы меток даже при закрытом приложении и записывает, где они были услышаны. Требует постоянного уведомления и расходует больше заряда. По умолчанию выключено. + Фоновый приём + Отображается, пока приложение принимает сигналы меток в фоне. + Приём сигналов меток + Нажмите, чтобы открыть карту. + Забыта + Предупреждает, когда метка перестала быть слышна, а вы от неё удалились. + %1$s осталась на месте + Последний сигнал там, где вы были %1$s. Нажмите, чтобы увидеть место. + Предупреждать, если забыта + Подаёт сигнал, когда метка перестала быть слышна, а вы ушли. Требует фонового приёма. + Предупредить через %1$d сек. + Сколько метка должна молчать, прежде чем приложение проверит, не забыли ли вы её. Меньше — раньше предупреждение и чаще проверки. + Звук будильника + Стандартный звук будильника + Подключите учётную запись Apple заново + Приложение хранит подключение к вашей учётной записи Apple, но больше не может его прочитать. Нужные ключи находятся в защищённом хранилище этого устройства и не подлежат восстановлению — поэтому подключение придётся выполнить заново. + +Ваши метки и история их местоположений не затронуты, и из учётной записи Apple ничего не удалено. + Сохранённое подключение к учётной записи Apple не удалось расшифровать, хотя его ключ на месте + Подключение к вашей учётной записи Apple хранится на этом устройстве, ключ к нему на месте, и оно всё равно не открывается — так быть не должно.\n\nПоэтому об этом стоит сообщить, а не просто настроить заново. Ваши метки и история их местоположений не затронуты, и из учётной записи Apple ничего не удалено. + Выравнивание ключей + Индекс %1$d, выровнено %2$s + Не сохранено — следующая загрузка начнёт поиск с даты сопряжения Apple приняла ваш код, а затем не смогла завершить вход. Код уже использован, поэтому нужен новый — подождём немного перед запросом, потому что сразу запрашивать бесполезно. Запросим новый код у Apple через %1$d с… Apple по-прежнему не завершает вход. Это сбой на стороне Apple — не ваша вина и не проблема кода.\n\nПопробуйте снова через несколько минут. Пароль при этом может быть отклонён один раз: это часть того же сбоя, поэтому введите его ещё раз, а не считайте неверным. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1a85cf5e..409323a6 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -310,6 +310,59 @@ 已与你的 Apple 账户同步 此刻无法连接你的 Apple 账户。标签没有变化。 此日志太大,无法复制。请改为保存为文件并附上该文件。 + 就近响铃 + 正在通过蓝牙搜索附近设备… + 正在播放声音。 + 附近未找到。请靠近后重试。 + 已连接,但此配件没有声音服务。 + 无法计算此配件当前的地址。 + 就近响铃需要蓝牙权限。 + 无法播放声音。请重试。 + 停止 + 已找到,正在连接… + 正在发送声音指令… + 搜索中… + 连接中… + 发送中… + 响铃中! + 在附近 + 在附近 · 电量%1$s + 刚刚通过蓝牙从标签本身读取,而非来自 iCloud。 + 充足 + 中等 + 偏低 + 极低 + 电量 + 在附近(%2$s)· 电量%1$s + 通过蓝牙 + 最后收到 + 信号强度 + 刚刚 + 在后台持续接收 + 即使应用已关闭也会接收标签,并记录听到的位置。需要常驻通知,耗电更多。默认关闭。 + 后台接收 + 应用在后台接收标签时显示。 + 正在接收标签 + 点按以打开地图。 + 遗落提醒 + 当标签不再被接收且你已离开时提醒你。 + %1$s 被落下了 + 在你%1$s所在的位置最后一次接收到。点按查看地点。 + 遗落时提醒 + 当此标签不再被接收且你已离开时发出提示音。需要开启后台接收。 + %1$d 秒后提醒 + 标签失去信号多久后,应用才检查你是否把它落下了。时间越短提醒越早,检查也越频繁。 + 报警声 + 默认报警声 + 请重新连接您的 Apple 账户 + 本应用保存了与你的 Apple 账户的连接,但已无法读取。所需的密钥保存在本设备的安全存储中,且无法恢复,因此需要重新建立连接。 + +你的标签及其位置历史不受影响,Apple 账户中也没有任何内容被移除。 + 已保存的 Apple 账户连接无法解密,但其密钥仍然存在 + 与你的 Apple 账户的连接就保存在本设备上,解开它的密钥也还在,却打不开了——这本不该发生。\n\n因此这值得报告,而不只是重做一次。你的标签及其位置历史不受影响,Apple 账户中也没有任何内容被移除。 + 密钥对齐 + 索引 %1$d,对齐于 %2$s + 未存储 — 下次获取将从配对日期开始搜索 Apple 已接受你的验证码,随后未能完成登录。该验证码已被用掉,需要一个新的——我们会先等一会儿再申请,因为立刻申请会被拒绝。 将在 %1$d 秒后向 Apple 申请新验证码… Apple 仍未完成登录。这是 Apple 一侧的故障,不是你的操作问题,也不是验证码的问题。\n\n请过几分钟再试。届时你的密码可能会被拒绝一次——这属于同一个故障,请再输入一次,而不要以为密码错了。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a0792b1d..0f9ec1d1 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -310,6 +310,59 @@ 已與你的 Apple 帳戶同步 此刻無法連線到你的 Apple 帳戶。標籤沒有變化。 此紀錄檔太大,無法複製。請改為儲存成檔案並附上該檔案。 + 就近響鈴 + 正在透過藍牙搜尋附近裝置… + 正在播放聲音。 + 附近未找到。請靠近後再試一次。 + 已連線,但此配件沒有聲音服務。 + 無法計算此配件目前的位址。 + 就近響鈴需要藍牙權限。 + 無法播放聲音。請重試。 + 停止 + 已找到,正在連線… + 正在傳送聲音指令… + 搜尋中… + 連線中… + 傳送中… + 響鈴中! + 在附近 + 在附近 · 電量%1$s + 剛剛透過藍牙從標籤本身讀取,而非來自 iCloud。 + 充足 + 中等 + 偏低 + 極低 + 電量 + 在附近(%2$s)· 電量%1$s + 透過藍牙 + 最後收到 + 訊號強度 + 剛剛 + 在背景持續接收 + 即使應用程式已關閉也會接收標籤,並記錄聽到的位置。需要常駐通知,較耗電。預設關閉。 + 背景接收 + 應用程式在背景接收標籤時顯示。 + 正在接收標籤 + 輕觸以開啟地圖。 + 遺留提醒 + 當標籤不再被接收且你已離開時提醒你。 + %1$s 被留下了 + 在你%1$s所在的位置最後一次接收到。輕觸查看地點。 + 遺留時提醒 + 當此標籤不再被接收且你已離開時發出提示音。需要開啟背景接收。 + %1$d 秒後提醒 + 標籤失去訊號多久後,應用程式才檢查你是否把它落下了。時間越短提醒越早,檢查也越頻繁。 + 警報聲 + 預設警報聲 + 請重新連接你的 Apple 帳戶 + 本應用程式儲存了與你的 Apple 帳戶的連線,但已無法讀取。所需的金鑰保存在本裝置的安全儲存空間中,且無法復原,因此必須重新建立連線。 + +你的標籤與其位置紀錄不受影響,Apple 帳戶中也沒有任何內容被移除。 + 已儲存的 Apple 帳戶連線無法解密,但其金鑰仍然存在 + 與你的 Apple 帳戶的連線就儲存在本裝置上,解開它的金鑰也還在,卻打不開了——這本不該發生。\n\n因此這值得回報,而不只是重做一次。你的標籤與其位置紀錄不受影響,Apple 帳戶中也沒有任何內容被移除。 + 金鑰對齊 + 索引 %1$d,對齊於 %2$s + 未儲存 — 下次擷取將從配對日期開始搜尋 Apple 已接受你的驗證碼,隨後未能完成登入。該驗證碼已被用掉,需要一個新的——我們會先等一下再申請,因為立刻申請會被拒絕。 將在 %1$d 秒後向 Apple 申請新驗證碼… Apple 仍未完成登入。這是 Apple 一側的故障,不是你的操作問題,也不是驗證碼的問題。\n\n請過幾分鐘再試。屆時你的密碼可能會被拒絕一次——這屬於同一個故障,請再輸入一次,而不要以為密碼錯了。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ce454d4..59a9287d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -94,6 +94,7 @@ Type Unknown Battery level + BLE status byte Device Model Pairing Date Product Id @@ -342,6 +343,59 @@ You can set this up now, or any time later from Settings. Up to date with your Apple account Could not reach your Apple account just now. Your tags are unchanged. This log is too large to copy. Save it as a file and attach that instead. + Play Sound Nearby + Searching nearby over Bluetooth… + Playing sound now. + Not found nearby. Move closer and try again. + Connected, but this accessory has no sound service. + Could not compute this accessory\'s current address. + Bluetooth permission is needed to play the sound nearby. + Could not play the sound. Try again. + Stop + Found nearby, connecting… + Sending sound command… + Scanning… + Connecting… + Sending… + Ringing! + Nearby + Nearby · Battery %1$s + Nearby (%2$s) · Battery %1$s + Read from the tag over Bluetooth just now, not from iCloud. + full + medium + low + critical + Battery + Over Bluetooth + Last seen + Signal + just now + Keep listening in the background + Listens for your tags even when the app is closed, and records where they were heard. Needs a permanent notification, and uses more battery. Off by default. + Background scanning + Shown while the app is listening for your tags in the background. + Listening for your tags + Tap to open the map. + Left behind + Alerts you when a tag stops being heard and you have moved away from it. + %1$s stayed behind + Last heard where you were %1$s. Tap to see the place. + Warn if left behind + Sounds an alarm when this tag stops being heard and you have moved on. Needs background listening to be on. + Warn after %1$d seconds + How long a tag has to go unheard before the app checks whether you have left it behind. Shorter catches you sooner and checks more often. + Alarm sound + Default alarm sound + Reconnect your Apple account + This app has a connection to your Apple account saved, but can no longer read it. The keys it needs are kept in this device\'s secure storage, and they cannot be recovered — so the connection has to be made again. + +Your tags and their location history are not affected, and nothing was removed from your Apple account. + The saved Apple account connection could not be decrypted, although its key is still present + The connection to your Apple account is stored on this device, the key that unlocks it is still here, and it no longer opens — which should not be possible.\n\nThat makes it worth reporting rather than just redoing. Your tags and their location history are not affected, and nothing was removed from your Apple account. + Key alignment + Index %1$d, aligned %2$s + None stored — the next fetch searches from the pairing date Apple accepted your code and then had a problem finishing the sign-in. The code is used up, so a new one is needed — waiting a moment before asking for it, because asking straight away is refused. Asking Apple for a new code in %1$d s… Apple is still not finishing the sign-in. This is a fault on Apple\'s side, not something you did, and not your code.\n\nTry again in a few minutes. Your password may be refused once when you do — that is part of the same fault, so enter it again rather than assuming it is wrong. diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessoryMatcherTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessoryMatcherTest.java new file mode 100644 index 00000000..3abb953d --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessoryMatcherTest.java @@ -0,0 +1,52 @@ +package dev.wander.android.opentagviewer.ble; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.Set; + +/** + * A JVM test on purpose: no Android in {@link BleAccessoryMatcher}, so this runs in the fast + * suite rather than needing an emulator - see its class doc for why the comparison was pulled + * out this way in the first place. + */ +public class BleAccessoryMatcherTest { + + private static final String CANDIDATE = "AA:BB:CC:DD:EE:FF"; + + @Test + public void matchesAnExactCandidate() { + assertTrue(BleAccessoryMatcher.matches(CANDIDATE, Set.of(CANDIDATE))); + } + + @Test + public void matchesRegardlessOfCase() { + assertTrue(BleAccessoryMatcher.matches( + CANDIDATE.toLowerCase(), Set.of(CANDIDATE.toUpperCase()))); + assertTrue(BleAccessoryMatcher.matches( + CANDIDATE.toUpperCase(), Set.of(CANDIDATE.toLowerCase()))); + } + + @Test + public void matchesOneOfSeveralCandidates() { + assertTrue(BleAccessoryMatcher.matches( + CANDIDATE, Set.of("11:22:33:44:55:66", CANDIDATE, "77:88:99:AA:BB:CC"))); + } + + @Test + public void doesNotMatchAnUnrelatedAddress() { + assertFalse(BleAccessoryMatcher.matches("11:22:33:44:55:66", Set.of(CANDIDATE))); + } + + @Test + public void doesNotMatchAgainstAnEmptyCandidateSet() { + assertFalse(BleAccessoryMatcher.matches(CANDIDATE, Set.of())); + } + + @Test + public void doesNotMatchANullScannedAddress() { + assertFalse(BleAccessoryMatcher.matches(null, Set.of(CANDIDATE))); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java new file mode 100644 index 00000000..f42109e1 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java @@ -0,0 +1,457 @@ +package dev.wander.android.opentagviewer.ble; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import dev.wander.android.opentagviewer.python.AccessoryMacResolver; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.observers.TestObserver; + +/** + * Exercises {@link BleAccessorySoundTrigger}'s own orchestration - the permission gate, the + * retry count, the continuous-repeat wiring - against fakes for the three seams that would + * otherwise need real Bluetooth hardware ({@link BleAccessorySoundTrigger.PermissionCheck}, + * {@link BleAccessorySoundTrigger.Scanner}, {@link BleAccessorySoundTrigger.GattTrigger}). + * + *

Uses {@code String} as the fake "found device" type - see the class doc on why {@code } + * exists at all. A JVM test on purpose: nothing here needs Android or a device. + */ +public class BleAccessorySoundTriggerTest { + + private static final String A_MAC = "AA:BB:CC:DD:EE:FF"; + private static final String A_DEVICE = A_MAC; + + /** The index the first candidate sits at, so a fed-back sighting is checkable. */ + private static final int A_KEY_INDEX = 4321; + private static final long AWAIT_SECONDS = 5; + + /** + * A resolver over a fixed candidate set, counting calls and remembering sightings. + * + *

A class rather than a lambda because {@link AccessoryMacResolver} gained a second + * method: a match is fed back through {@code recordSeen} so the next scan can collapse to a + * single key index, and a one-method interface cannot express both halves. + */ + private static final class FakeResolver implements AccessoryMacResolver { + private final Map candidates; + private final AtomicInteger resolveCalls = new AtomicInteger(); + private final List sightings = new ArrayList<>(); + + private FakeResolver(final Map candidates) { + this.candidates = candidates; + } + + @Override + public Map currentMacAddresses(final String accessoryJson) { + this.resolveCalls.incrementAndGet(); + return this.candidates; + } + + @Override + public String recordSeen( + final String accessoryJson, final String mac, final long seenAtUnixMs, + final Integer hintIndex) { + this.sightings.add(mac); + return "{\"aligned\":true}"; + } + } + + /** Candidates at made-up indices, so "which one matched" is visible in an assertion. */ + private static FakeResolver resolverReturning(final List macs) { + final Map candidates = new LinkedHashMap<>(); + for (int i = 0; i < macs.size(); i++) { + candidates.put(macs.get(i), A_KEY_INDEX + i); + } + return new FakeResolver(candidates); + } + + private static BleSoundTriggerUpdate doneUpdate(final BleSoundTriggerStatus status) { + return BleSoundTriggerUpdate.done(new BleSoundTriggerResult(status, null, "test")); + } + + // --- permission gate -------------------------------------------------------------------- + + @Test + public void missingPermissionShortCircuitsBeforeResolvingAnyMac() throws InterruptedException { + final FakeResolver resolver = resolverReturning(List.of(A_MAC)); + final AtomicInteger resolverCalls = resolver.resolveCalls; + + final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>( + resolver, + context -> false, + unreachableScanner(), + unreachableGattTrigger(), + s -> s, + 3, 0L, 0L); + + final List items = playSoundBlocking(trigger); + + assertEquals(1, items.size()); + assertEquals(BleSoundTriggerStatus.MISSING_PERMISSION, items.get(0).getResult().getStatus()); + assertEquals("a denied permission must not even ask for a MAC address", + 0, resolverCalls.get()); + } + + // --- MAC resolution ----------------------------------------------------------------------- + + @Test + public void noCandidateMacsShortCircuitsBeforeScanning() throws InterruptedException { + final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>( + resolverReturning(List.of()), + context -> true, + unreachableScanner(), + unreachableGattTrigger(), + s -> s, + 3, 0L, 0L); + + final List items = playSoundBlocking(trigger); + + assertEquals(1, items.size()); + assertEquals(BleSoundTriggerStatus.NO_CANDIDATE_MACS, items.get(0).getResult().getStatus()); + } + + /** + * The resolver's other way of saying it has no addresses: null, not an empty map. + * + *

It answers that for an accessory it cannot read, and for one whose candidate window is + * too wide to be worth deriving - which is what an owner's own Apple device is, since a + * phone has no rolling-key alignment and reaches this code through "show my own Apple + * devices". Both mean the same thing to a ring attempt: there is nothing to scan for. Being + * dereferenced instead turned that into a thrown chain and an error toast. + */ + @Test + public void aResolverThatRefusesIsTreatedAsNoCandidatesRatherThanThrowing() + throws InterruptedException { + final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>( + new FakeResolver(null), + context -> true, + unreachableScanner(), + unreachableGattTrigger(), + s -> s, + 3, 0L, 0L); + + final List items = playSoundBlocking(trigger); + + assertEquals(1, items.size()); + assertEquals(BleSoundTriggerStatus.NO_CANDIDATE_MACS, items.get(0).getResult().getStatus()); + } + + // --- the happy path --------------------------------------------------------------------- + + @Test + public void aSuccessfulRunEmitsScanningThenWhateverTheGattTriggerEmits() throws InterruptedException { + final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>( + resolverReturning(List.of(A_MAC)), + context -> true, + (context, macs, timeout) -> Single.just(A_DEVICE), + (context, device) -> Observable.just( + BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.CONNECTING), + BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.TRIGGERING), + doneUpdate(BleSoundTriggerStatus.SUCCESS)), + s -> s, + 3, 0L, 0L); + + final List items = playSoundBlocking(trigger); + + assertEquals(4, items.size()); + assertEquals(BleSoundTriggerPhase.SCANNING, items.get(0).getPhase()); + assertEquals(BleSoundTriggerPhase.CONNECTING, items.get(1).getPhase()); + assertEquals(BleSoundTriggerPhase.TRIGGERING, items.get(2).getPhase()); + assertEquals(BleSoundTriggerStatus.SUCCESS, items.get(3).getResult().getStatus()); + } + + /** + * A match reports the address that answered, not the index it was resolved with. + * + *

Only Python can tell a primary key's index from a secondary key's - see + * {@link AccessoryMacResolver#recordSeen} - so this package reports the raw address and + * leaves that judgment to the caller's next call across the bridge. + */ + @Test + public void aSuccessfulMatchReportsWhichMacAnswered() throws InterruptedException { + final String anotherMac = "11:22:33:44:55:66"; + + final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>( + resolverReturning(List.of(A_MAC, anotherMac)), + context -> true, + // The *second* candidate answers, so a hardcoded first address cannot pass. + (context, macs, timeout) -> Single.just(anotherMac), + (context, device) -> Observable.just(doneUpdate(BleSoundTriggerStatus.SUCCESS)), + s -> s, + 3, 0L, 0L); + + final List items = playSoundBlocking(trigger); + + final BleSoundTriggerUpdate done = items.get(items.size() - 1); + assertEquals(BleSoundTriggerStatus.SUCCESS, done.getResult().getStatus()); + assertEquals("the address of the candidate that actually answered", + anotherMac, done.getResult().getMatchedMac()); + } + + /** + * And it reports it even when the sound then failed. + * + *

The tag was there - that is what the scan proved, and it stays true whether or not the + * GATT exchange worked. Dropping the address on failure would mean the case most likely to + * be retried is also the one that keeps paying for the wide search. + */ + @Test + public void afailedTriggerStillReportsThatTheTagWasSeen() throws InterruptedException { + final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>( + resolverReturning(List.of(A_MAC)), + context -> true, + (context, macs, timeout) -> Single.just(A_DEVICE), + (context, device) -> Observable.just( + doneUpdate(BleSoundTriggerStatus.NO_SOUND_SERVICE)), + s -> s, + 3, 0L, 0L); + + final List items = playSoundBlocking(trigger); + + final BleSoundTriggerUpdate done = items.get(items.size() - 1); + assertEquals(BleSoundTriggerStatus.NO_SOUND_SERVICE, done.getResult().getStatus()); + assertEquals(A_MAC, done.getResult().getMatchedMac()); + } + + /** Nothing found means nothing to report - there is no sighting to record. */ + @Test + public void anUnfoundTagReportsNoMac() throws InterruptedException { + final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>( + resolverReturning(List.of(A_MAC)), + context -> true, + (context, macs, timeout) -> + Single.error(new NearbyAccessoryScanner.NotNearbyException()), + unreachableGattTrigger(), + s -> s, + 3, 0L, 0L); + + final List items = playSoundBlocking(trigger); + + final BleSoundTriggerUpdate done = items.get(items.size() - 1); + assertEquals(BleSoundTriggerStatus.NOT_NEARBY, done.getResult().getStatus()); + assertNull(done.getResult().getMatchedMac()); + } + + @Test + public void theCandidateMacsPassedToTheScannerComeFromTheResolver() throws InterruptedException { + final AtomicInteger seenCandidateCount = new AtomicInteger(-1); + + final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>( + resolverReturning(List.of(A_MAC, "11:22:33:44:55:66")), + context -> true, + (context, macs, timeout) -> { + seenCandidateCount.set(macs.size()); + return Single.just(A_DEVICE); + }, + (context, device) -> Observable.just(doneUpdate(BleSoundTriggerStatus.SUCCESS)), + s -> s, + 3, 0L, 0L); + + playSoundBlocking(trigger); + + assertEquals(2, seenCandidateCount.get()); + } + + // --- retry ------------------------------------------------------------------------------ + + @Test + public void aFailedAttemptIsRetriedUpToTheAttemptLimit() throws InterruptedException { + final AtomicInteger gattCalls = new AtomicInteger(); + + final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>( + resolverReturning(List.of(A_MAC)), + context -> true, + (context, macs, timeout) -> Single.just(A_DEVICE), + (context, device) -> { + gattCalls.incrementAndGet(); + return Observable.just(doneUpdate(BleSoundTriggerStatus.FAILED)); + }, + s -> s, + 3, 0L, 0L); + + final List items = playSoundBlocking(trigger); + + assertEquals("FAILED should be retried until the attempt limit", 3, gattCalls.get()); + assertEquals(BleSoundTriggerStatus.FAILED, + items.get(items.size() - 1).getResult().getStatus()); + } + + @Test + public void aSuccessfulRetryStopsFurtherAttempts() throws InterruptedException { + final AtomicInteger gattCalls = new AtomicInteger(); + + final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>( + resolverReturning(List.of(A_MAC)), + context -> true, + (context, macs, timeout) -> Single.just(A_DEVICE), + (context, device) -> Observable.just(gattCalls.incrementAndGet() == 1 + ? doneUpdate(BleSoundTriggerStatus.FAILED) + : doneUpdate(BleSoundTriggerStatus.SUCCESS)), + s -> s, + 3, 0L, 0L); + + final List items = playSoundBlocking(trigger); + + assertEquals("should have stopped after the second, successful attempt", 2, gattCalls.get()); + assertEquals(BleSoundTriggerStatus.SUCCESS, + items.get(items.size() - 1).getResult().getStatus()); + } + + @Test + public void noSoundServiceIsNeverRetried() throws InterruptedException { + final AtomicInteger gattCalls = new AtomicInteger(); + + final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>( + resolverReturning(List.of(A_MAC)), + context -> true, + (context, macs, timeout) -> Single.just(A_DEVICE), + (context, device) -> { + gattCalls.incrementAndGet(); + return Observable.just(doneUpdate(BleSoundTriggerStatus.NO_SOUND_SERVICE)); + }, + s -> s, + 3, 0L, 0L); + + final List items = playSoundBlocking(trigger); + + assertEquals("connecting worked and found nothing recognisable - a retry cannot fix that", + 1, gattCalls.get()); + assertEquals(BleSoundTriggerStatus.NO_SOUND_SERVICE, + items.get(items.size() - 1).getResult().getStatus()); + } + + // --- scanner failure modes ---------------------------------------------------------------- + + @Test + public void aScannerTimeoutMapsToNotNearby() throws InterruptedException { + final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>( + resolverReturning(List.of(A_MAC)), + context -> true, + (context, macs, timeout) -> Single.error(new NearbyAccessoryScanner.NotNearbyException()), + unreachableGattTrigger(), + s -> s, + 3, 0L, 0L); + + final List items = playSoundBlocking(trigger); + + assertEquals(BleSoundTriggerStatus.NOT_NEARBY, + items.get(items.size() - 1).getResult().getStatus()); + } + + @Test + public void anUnexpectedScannerErrorMapsToFailedRatherThanCrashing() throws InterruptedException { + final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>( + resolverReturning(List.of(A_MAC)), + context -> true, + (context, macs, timeout) -> Single.error(new IllegalStateException("radio is off")), + unreachableGattTrigger(), + s -> s, + 3, 0L, 0L); + + final TestObserver observer = trigger.playSound(null, "{}").test(); + assertTrue(observer.await(AWAIT_SECONDS, TimeUnit.SECONDS)); + observer.assertComplete(); // the never-errors contract - see AccessorySoundTrigger's doc + observer.assertNoErrors(); + + final List items = observer.values(); + assertEquals(BleSoundTriggerStatus.FAILED, + items.get(items.size() - 1).getResult().getStatus()); + } + + // --- continuous ping ---------------------------------------------------------------------- + + @Test + public void continuousPingRepeatsAfterEachCycleUntilDisposed() throws InterruptedException { + final AtomicInteger scannerCalls = new AtomicInteger(); + final CountDownLatch sawThreeCycles = new CountDownLatch(1); + + final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>( + resolverReturning(List.of(A_MAC)), + context -> true, + (context, macs, timeout) -> { + if (scannerCalls.incrementAndGet() >= 3) { + sawThreeCycles.countDown(); + } + return Single.error(new NearbyAccessoryScanner.NotNearbyException()); + }, + unreachableGattTrigger(), + s -> s, + 3, 0L, 1L); // 1ms pause - fast, but still an async repeatWhen delay + + final Disposable subscription = trigger.playSoundContinuously(null, "{}") + .subscribe(update -> { }, error -> fail("playSoundContinuously must never error")); + try { + assertTrue("expected at least 3 scan cycles within " + AWAIT_SECONDS + "s, got " + + scannerCalls.get(), + sawThreeCycles.await(AWAIT_SECONDS, TimeUnit.SECONDS)); + } finally { + subscription.dispose(); + } + } + + @Test + public void disposingContinuousPingStopsFurtherCycles() throws InterruptedException { + final AtomicInteger scannerCalls = new AtomicInteger(); + + final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>( + resolverReturning(List.of(A_MAC)), + context -> true, + (context, macs, timeout) -> { + scannerCalls.incrementAndGet(); + return Single.error(new NearbyAccessoryScanner.NotNearbyException()); + }, + unreachableGattTrigger(), + s -> s, + 3, 0L, 1L); + + final Disposable subscription = trigger.playSoundContinuously(null, "{}") + .subscribe(update -> { }, error -> fail("playSoundContinuously must never error")); + + // Give it a moment to run a few cycles, then stop it. + Thread.sleep(200); + subscription.dispose(); + final int callsAtDispose = scannerCalls.get(); + Thread.sleep(200); + + assertEquals("a cycle ran after dispose - the loop was not actually stopped", + callsAtDispose, scannerCalls.get()); + } + + // --- helpers ------------------------------------------------------------------------------ + + private static List playSoundBlocking( + final BleAccessorySoundTrigger trigger) throws InterruptedException { + final TestObserver observer = trigger.playSound(null, "{}").test(); + assertTrue("playSound did not complete within " + AWAIT_SECONDS + "s", + observer.await(AWAIT_SECONDS, TimeUnit.SECONDS)); + observer.assertComplete(); + observer.assertNoErrors(); + return observer.values(); + } + + private static BleAccessorySoundTrigger.Scanner unreachableScanner() { + return (context, macs, timeout) -> { + throw new AssertionError("scanner should not have been called"); + }; + } + + private static BleAccessorySoundTrigger.GattTrigger unreachableGattTrigger() { + return (context, device) -> { + throw new AssertionError("gatt trigger should not have been called"); + }; + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/DerivedAddressStoreTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/DerivedAddressStoreTest.java new file mode 100644 index 00000000..62b3e4b2 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/DerivedAddressStoreTest.java @@ -0,0 +1,219 @@ +package dev.wander.android.opentagviewer.ble; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * What the derived-address cache has to guarantee, which is less than it looks. + * + *

It is allowed to lose everything at any time: a miss costs a derivation, which is what + * would have happened without it. What it must never do is claim to hold a range it does not, + * because nothing would ever go back and derive the part that was silently missing. + */ +public class DerivedAddressStoreTest { + + private static final String BEACON = "ABCDEF01-2345-6789-ABCD-EF0123456789"; + + @Rule + public TemporaryFolder files = new TemporaryFolder(); + + private DerivedAddressStore store() { + return new DerivedAddressStore(this.files.getRoot()); + } + + private static Map addresses(final String... macs) { + final Map out = new HashMap<>(); + for (int i = 0; i < macs.length; i++) { + out.put(macs[i], i); + } + return out; + } + + @Test + public void nothingIsHeldForATagThatWasNeverWritten() { + assertNull(this.store().load(BEACON)); + } + + @Test + public void whatWasWrittenComesBack() { + final DerivedAddressStore store = this.store(); + store.save(BEACON, 100, 200, addresses("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02")); + + final DerivedAddressStore.Derived held = store.load(BEACON); + + assertNotNull(held); + assertEquals(100, held.getLo()); + assertEquals(200, held.getHi()); + assertEquals(Set.of("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"), + held.getAddresses().keySet()); + } + + /** A primary key sits at one index forever, so its hint survives the round trip. */ + @Test + public void aKnownIndexComesBackExactly() { + final DerivedAddressStore store = this.store(); + + final Map known = new HashMap<>(); + known.put("AA:BB:CC:DD:EE:01", 7_412); + store.save(BEACON, 0, 10_000, known); + + final DerivedAddressStore.Derived held = store.load(BEACON); + + assertNotNull(held); + assertEquals(Integer.valueOf(7_412), held.getAddresses().get("AA:BB:CC:DD:EE:01")); + } + + /** + * An index that meant nothing when it was written must read back as absent rather than as + * some plausible-looking number a caller might trust. See {@link DerivedAddressStore}. + */ + @Test + public void anUnknownIndexReadsBackAsNoHint() { + final DerivedAddressStore store = this.store(); + + final Map unknown = new HashMap<>(); + unknown.put("AA:BB:CC:DD:EE:01", null); + store.save(BEACON, 0, 10, unknown); + + final DerivedAddressStore.Derived held = store.load(BEACON); + + assertNotNull(held); + assertTrue(held.getAddresses().containsKey("AA:BB:CC:DD:EE:01")); + assertNull(held.getAddresses().get("AA:BB:CC:DD:EE:01")); + } + + @Test + public void coverageIsReportedForTheStoredRangeOnly() { + final DerivedAddressStore store = this.store(); + store.save(BEACON, 100, 200, addresses("AA:BB:CC:DD:EE:01")); + + final DerivedAddressStore.Derived held = store.load(BEACON); + + assertNotNull(held); + assertTrue(held.covers(120, 180)); + assertTrue(held.covers(100, 200)); + assertFalse("a range starting below what was derived is not covered", held.covers(99, 200)); + assertFalse("a range ending above what was derived is not covered", held.covers(100, 201)); + } + + @Test + public void writingAgainReplacesWhatWasThere() { + final DerivedAddressStore store = this.store(); + store.save(BEACON, 100, 200, addresses("AA:BB:CC:DD:EE:01")); + store.save(BEACON, 50, 200, addresses("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02")); + + final DerivedAddressStore.Derived held = store.load(BEACON); + + assertNotNull(held); + assertEquals(50, held.getLo()); + assertEquals(2, held.getAddresses().size()); + } + + @Test + public void twoTagsDoNotShareAFile() { + final DerivedAddressStore store = this.store(); + store.save(BEACON, 0, 10, addresses("AA:BB:CC:DD:EE:01")); + store.save("OTHER-TAG", 0, 10, addresses("AA:BB:CC:DD:EE:02")); + + assertEquals(Set.of("AA:BB:CC:DD:EE:01"), store.load(BEACON).getAddresses().keySet()); + assertEquals(Set.of("AA:BB:CC:DD:EE:02"), store.load("OTHER-TAG").getAddresses().keySet()); + } + + /** + * A half-written file must read as nothing rather than as a shorter range. Reading it as a + * shorter range is the one failure that would not announce itself: the missing part would be + * derived again on every launch, and nothing would ever say why. + */ + @Test + public void aTruncatedFileIsTreatedAsNothingHeld() throws IOException { + final DerivedAddressStore store = this.store(); + store.save(BEACON, 100, 200, addresses("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02")); + + final File file = new File(new File(this.files.getRoot(), "derived-addresses"), + BEACON + ".bin"); + assertTrue(file.isFile()); + + final byte[] whole = java.nio.file.Files.readAllBytes(file.toPath()); + try (FileOutputStream out = new FileOutputStream(file)) { + out.write(whole, 0, whole.length - 3); + } + + assertNull(store.load(BEACON)); + } + + @Test + public void aFileFromAnotherFormatIsDiscarded() throws IOException { + final DerivedAddressStore store = this.store(); + store.save(BEACON, 100, 200, addresses("AA:BB:CC:DD:EE:01")); + + final File file = new File(new File(this.files.getRoot(), "derived-addresses"), + BEACON + ".bin"); + final byte[] whole = java.nio.file.Files.readAllBytes(file.toPath()); + whole[3] = (byte) 99; + try (FileOutputStream out = new FileOutputStream(file)) { + out.write(whole); + } + + assertNull(store.load(BEACON)); + } + + @Test + public void tagsTheUserNoLongerHasAreForgotten() { + final DerivedAddressStore store = this.store(); + store.save(BEACON, 0, 10, addresses("AA:BB:CC:DD:EE:01")); + store.save("GONE-FROM-THE-ACCOUNT", 0, 10, addresses("AA:BB:CC:DD:EE:02")); + + store.forgetAllExcept(Set.of(BEACON)); + + assertNotNull(store.load(BEACON)); + assertNull(store.load("GONE-FROM-THE-ACCOUNT")); + } + + /** + * Beacon ids arrive from an imported file, and this builds a path with one. A separator in + * the id must not put the file somewhere else, and must still round-trip. + */ + @Test + public void anIdThatLooksLikeAPathStaysInsideTheDirectory() { + final DerivedAddressStore store = this.store(); + store.save("../../etc/passwd", 0, 10, addresses("AA:BB:CC:DD:EE:01")); + + final File directory = new File(this.files.getRoot(), "derived-addresses"); + final File[] written = directory.listFiles(); + + assertNotNull(written); + assertEquals(1, written.length); + assertFalse(written[0].getName().contains("/")); + assertNotNull(store.load("../../etc/passwd")); + } + + @Test + public void anAddressThatIsNotAnAddressIsDroppedRatherThanCorrupting() { + final DerivedAddressStore store = this.store(); + + final Map mixed = new HashMap<>(); + mixed.put("AA:BB:CC:DD:EE:01", 1); + mixed.put("not an address", 2); + store.save(BEACON, 0, 10, mixed); + + final DerivedAddressStore.Derived held = store.load(BEACON); + + // The count in the header claimed two; only one was written. Reading must not invent a + // second one out of whatever followed. + assertNull("a short file is not a partly-good file", held); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisementTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisementTest.java new file mode 100644 index 00000000..122002d6 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisementTest.java @@ -0,0 +1,99 @@ +package dev.wander.android.opentagviewer.ble; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +import dev.wander.android.opentagviewer.ble.FindMyAdvertisement.BatteryLevel; +import dev.wander.android.opentagviewer.ble.FindMyAdvertisement.State; + +/** + * The payload rules, pinned against real captures. + * + *

Every byte sequence below was observed in an actual scan on a Pixel 10 Pro, rather than + * constructed from the spec, so a change here fails against what accessories really send. + */ +public class FindMyAdvertisementTest { + + /** A separated accessory, battery full. Captured from the author's own tag. */ + private static final byte[] SEPARATED_FULL = {0x12, 0x19, 0x20}; + + /** A separated accessory reporting a low battery. Captured from a stranger's tag nearby. */ + private static final byte[] SEPARATED_LOW = {0x12, 0x19, (byte) 0x90}; + + /** The short form, sent while the owner is present. Captured repeatedly. */ + private static final byte[] OWNER_NEARBY = {0x12, 0x02, 0x00}; + + @Test + public void readsTheSeparatedState() { + assertEquals(State.SEPARATED, FindMyAdvertisement.parse(SEPARATED_FULL).getState()); + } + + @Test + public void readsTheOwnerNearbyState() { + assertEquals(State.OWNER_NEARBY, FindMyAdvertisement.parse(OWNER_NEARBY).getState()); + } + + /** + * Only 0x19 means separated. Anything else is the short form, which is how AirGuard + * reads it too. Pinned because treating an unknown length as "separated" would have us + * announce a tag as reachable when it is not. + */ + @Test + public void anyLengthOtherThanTheFullBeaconCountsAsOwnerNearby() { + assertEquals(State.OWNER_NEARBY, + FindMyAdvertisement.parse(new byte[] {0x12, 0x0A, 0x00}).getState()); + } + + @Test + public void readsTheBatteryLevelFromTheTopTwoBits() { + assertEquals(BatteryLevel.FULL, FindMyAdvertisement.parse(SEPARATED_FULL).getBatteryLevel()); + assertEquals(BatteryLevel.LOW, FindMyAdvertisement.parse(SEPARATED_LOW).getBatteryLevel()); + } + + @Test + public void coversAllFourBatteryLevels() { + assertEquals(BatteryLevel.FULL, + FindMyAdvertisement.parse(new byte[] {0x12, 0x19, 0x00}).getBatteryLevel()); + assertEquals(BatteryLevel.MEDIUM, + FindMyAdvertisement.parse(new byte[] {0x12, 0x19, 0x40}).getBatteryLevel()); + assertEquals(BatteryLevel.LOW, + FindMyAdvertisement.parse(new byte[] {0x12, 0x19, (byte) 0x80}).getBatteryLevel()); + assertEquals(BatteryLevel.VERY_LOW, + FindMyAdvertisement.parse(new byte[] {0x12, 0x19, (byte) 0xC0}).getBatteryLevel()); + } + + /** + * The status byte is kept raw as well as interpreted. A bug report quoting 0x90 is + * answerable; one quoting "Low" is not, if the reading itself is what is wrong. + */ + @Test + public void keepsTheRawStatusByteUnsigned() { + assertEquals(0x90, FindMyAdvertisement.parse(SEPARATED_LOW).getStatusByte()); + } + + // --- what is not a Find My advertisement ------------------------------------------------- + + @Test + public void ignoresDevicesWithNoAppleData() { + assertNull(FindMyAdvertisement.parse(null)); + } + + /** + * Apple broadcasts plenty of other types - handoff, nearby-info, and so on. Captured + * examples: 0x10, 0x0F, 0x13, 0x09. None of them are ours. + */ + @Test + public void ignoresOtherAppleAdvertisementTypes() { + assertNull(FindMyAdvertisement.parse(new byte[] {0x10, 0x05, 0x03})); + assertNull(FindMyAdvertisement.parse(new byte[] {0x0F, 0x05, (byte) 0x90})); + assertNull(FindMyAdvertisement.parse(new byte[] {0x13, 0x08, 0x4A})); + } + + @Test + public void ignoresAPayloadTooShortToRead() { + assertNull(FindMyAdvertisement.parse(new byte[] {0x12, 0x19})); + assertNull(FindMyAdvertisement.parse(new byte[] {})); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexStoreTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexStoreTest.java new file mode 100644 index 00000000..8bff05a5 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexStoreTest.java @@ -0,0 +1,187 @@ +package dev.wander.android.opentagviewer.ble; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import androidx.annotation.Nullable; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import dev.wander.android.opentagviewer.python.AccessoryMacResolver; + +/** + * What the index derives when it already holds some of the answer. + * + *

The point of keeping derived addresses is not deriving them again, so these tests are + * mostly about what is not asked for. The one that matters most is the gap: a range held + * from an earlier session and a window that has since moved past it must end up joined, because + * the alternative - starting over - throws away hours of widening for a tag nobody has heard, + * which is the tag the widening was for. + */ +public class NearbyTagIndexStoreTest { + + private static final String BEACON = "A-TAG"; + private static final String JSON = "{\"accessory\":true}"; + + @Rule + public TemporaryFolder files = new TemporaryFolder(); + + private DerivedAddressStore store; + private RecordingResolver resolver; + private NearbyTagIndex index; + + private static final class RecordingResolver implements AccessoryMacResolver { + private final List derived = new ArrayList<>(); + private int windowLo = 9_617; + private int windowHi = 10_000; + + @Override + public Map currentMacAddresses(final String accessoryJson) { + throw new AssertionError("the store path must not fall back to currentMacAddresses"); + } + + @Override + @Nullable + public IndexRange candidateWindow(final String accessoryJson) { + return new IndexRange(this.windowLo, this.windowHi); + } + + @Override + public Map addressesBetween( + final String accessoryJson, final int lo, final int hi) { + this.derived.add(new int[]{lo, hi}); + + final Map out = new HashMap<>(); + for (int i = lo; i <= hi; i++) { + out.put(macFor(i), i); + } + return out; + } + } + + private static String macFor(final int index) { + return String.format("AA:BB:CC:%02X:%02X:%02X", + (index >> 16) & 0xFF, (index >> 8) & 0xFF, index & 0xFF); + } + + @Before + public void setUp() { + this.store = new DerivedAddressStore(this.files.getRoot()); + this.resolver = new RecordingResolver(); + this.index = new NearbyTagIndex(); + } + + private void rebuild(final long nowMs) { + this.index.rebuild(Map.of(BEACON, JSON), this.resolver, nowMs, this.store); + } + + @Test + public void anEmptyStoreDerivesTheWholeWindowAndKeepsIt() { + this.rebuild(0L); + + assertEquals(1, this.resolver.derived.size()); + assertEquals(9_617, this.resolver.derived.get(0)[0]); + assertEquals(10_000, this.resolver.derived.get(0)[1]); + + final DerivedAddressStore.Derived held = this.store.load(BEACON); + assertNotNull(held); + assertEquals(9_617, held.getLo()); + assertEquals(10_000, held.getHi()); + } + + @Test + public void aSecondRebuildAtTheSameMomentDerivesNothing() { + this.rebuild(0L); + this.resolver.derived.clear(); + + this.rebuild(1_000L); + + assertTrue("the window had not moved, so there was nothing to derive", + this.resolver.derived.isEmpty()); + assertNotNull(this.index.matchFor(macFor(9_800))); + } + + @Test + public void onlyTheIndicesTheWindowHasMovedOnToAreDerived() { + this.rebuild(0L); + this.resolver.derived.clear(); + + this.resolver.windowLo = 9_620; + this.resolver.windowHi = 10_003; + this.rebuild(1_000L); + + assertEquals(1, this.resolver.derived.size()); + assertEquals("only the three new indices at the top", 10_001, this.resolver.derived.get(0)[0]); + assertEquals(10_003, this.resolver.derived.get(0)[1]); + } + + /** + * The app left closed for a few days: the window has moved past what is held, so the two no + * longer touch. The gap must be derived along with the window, and the widened bottom kept. + */ + @Test + public void aWindowThatHasMovedPastWhatIsHeldJoinsUpRatherThanStartingOver() { + this.store.save(BEACON, 400, 10_000, Map.of(macFor(400), 400)); + + this.resolver.windowLo = 10_600; + this.resolver.windowHi = 10_983; + this.rebuild(0L); + + assertEquals(1, this.resolver.derived.size()); + assertEquals("the gap must be derived, not skipped", 10_001, this.resolver.derived.get(0)[0]); + assertEquals(10_983, this.resolver.derived.get(0)[1]); + + final DerivedAddressStore.Derived held = this.store.load(BEACON); + assertNotNull(held); + assertEquals("the widened bottom must survive", 400, held.getLo()); + assertEquals(10_983, held.getHi()); + assertTrue(held.getAddresses().containsKey(macFor(400))); + assertTrue(held.getAddresses().containsKey(macFor(10_500))); + } + + /** + * A tag that turns up again after a long absence is matched from the widened part of the + * store, which is the whole reason for keeping it. + */ + @Test + public void anAddressFromTheWidenedPartStillMatches() { + this.store.save(BEACON, 400, 10_000, Map.of(macFor(450), 450)); + + this.resolver.windowLo = 9_617; + this.resolver.windowHi = 10_000; + this.rebuild(0L); + + final NearbyTagIndex.Match match = this.index.matchFor(macFor(450)); + + assertNotNull("an address derived hours ago must still be matched", match); + assertEquals(BEACON, match.getBeaconId()); + // A primary key sits at one index forever, so the stored index is an exact hint. That + // is what lets a tag found this way confirm its alignment with three derivations + // instead of a search of a window that, by definition, does not contain it. + assertEquals(Integer.valueOf(450), match.getKeyIndex()); + } + + @Test + public void aRangeGrownPastTheCapIsStartedOver() { + this.store.save(BEACON, 0, 10_000, Map.of(macFor(0), 0)); + + this.resolver.windowLo = NearbyTagIndex.MAX_STORED_INDICES + 100; + this.resolver.windowHi = NearbyTagIndex.MAX_STORED_INDICES + 483; + this.rebuild(0L); + + final DerivedAddressStore.Derived held = this.store.load(BEACON); + assertNotNull(held); + assertEquals("past the cap the oldest part is certainly dead and is dropped", + NearbyTagIndex.MAX_STORED_INDICES + 100, held.getLo()); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexTest.java new file mode 100644 index 00000000..c1746057 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexTest.java @@ -0,0 +1,205 @@ +package dev.wander.android.opentagviewer.ble; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import dev.wander.android.opentagviewer.python.AccessoryMacResolver; + +/** A JVM test: {@link NearbyTagIndex} has no Android and no Bluetooth in it, deliberately. */ +public class NearbyTagIndexTest { + + private static final String KEYS = "keys-beacon-id"; + private static final String BIKE = "bike-beacon-id"; + + private static Map twoTags() { + final Map tags = new HashMap<>(); + tags.put(KEYS, "{\"type\":\"accessory\",\"tag\":\"keys\"}"); + tags.put(BIKE, "{\"type\":\"accessory\",\"tag\":\"bike\"}"); + return tags; + } + + /** The candidate map shape currentMacAddresses returns; this class only reads its keys, so + * the indices are arbitrary placeholders. */ + private static Map macs(final String... addresses) { + final Map byMac = new HashMap<>(); + for (int i = 0; i < addresses.length; i++) { + byMac.put(addresses[i], i); + } + return byMac; + } + + /** Answers a different address set per accessory, so a mix-up between tags would show. */ + private static AccessoryMacResolver resolverFor(final Map> byJson) { + return json -> byJson.getOrDefault(json, Map.of()); + } + + @Test + public void mapsEveryCandidateAddressBackToItsTag() { + final Map> answers = new HashMap<>(); + answers.put("{\"type\":\"accessory\",\"tag\":\"keys\"}", + macs("AA:AA:AA:AA:AA:01", "AA:AA:AA:AA:AA:02")); + answers.put("{\"type\":\"accessory\",\"tag\":\"bike\"}", + macs("BB:BB:BB:BB:BB:01")); + + final NearbyTagIndex index = new NearbyTagIndex(); + index.rebuild(twoTags(), resolverFor(answers), 0L); + + assertEquals(3, index.size()); + assertEquals(KEYS, index.matchFor("AA:AA:AA:AA:AA:01").getBeaconId()); + assertEquals(KEYS, index.matchFor("AA:AA:AA:AA:AA:02").getBeaconId()); + assertEquals(BIKE, index.matchFor("BB:BB:BB:BB:BB:01").getBeaconId()); + } + + @Test + public void anAddressThatIsNotOursResolvesToNothing() { + final NearbyTagIndex index = new NearbyTagIndex(); + index.rebuild(twoTags(), resolverFor(Map.of()), 0L); + + assertNull(index.matchFor("CC:CC:CC:CC:CC:CC")); + assertNull(index.matchFor(null)); + } + + /** Neither side promises a casing forever, and a casing mismatch would present as + * "the tag is never nearby" rather than as anything failing. */ + @Test + public void matchingIgnoresCase() { + final NearbyTagIndex index = new NearbyTagIndex(); + index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of("j", macs("aa:bb:cc:dd:ee:ff"))), 0L); + + assertEquals(KEYS, index.matchFor("AA:BB:CC:DD:EE:FF").getBeaconId()); + assertEquals(KEYS, index.matchFor("aa:bb:cc:dd:ee:ff").getBeaconId()); + } + + // --- expiry ------------------------------------------------------------------------------- + + @Test + public void aFreshlyConstructedIndexIsStale() { + assertTrue(new NearbyTagIndex().isStale(0L)); + } + + @Test + public void staysFreshInsideTheWindowAndExpiresAtIt() { + final NearbyTagIndex index = new NearbyTagIndex(); + index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of()), 1_000L); + + assertFalse(index.isStale(1_000L)); + assertFalse(index.isStale(1_000L + NearbyTagIndex.MAX_AGE_MS - 1)); + assertTrue("must expire before the 15 minute rollover, or sightings are missed", + index.isStale(1_000L + NearbyTagIndex.MAX_AGE_MS)); + } + + @Test + public void expiryIsShorterThanTheRolloverInterval() { + assertTrue("an index older than a rollover predicts addresses nothing is sending any more", + NearbyTagIndex.MAX_AGE_MS < java.util.concurrent.TimeUnit.MINUTES.toMillis(15)); + } + + // --- rebuilding --------------------------------------------------------------------------- + + @Test + public void rebuildingReplacesTheOldAddressesRatherThanAccumulating() { + final NearbyTagIndex index = new NearbyTagIndex(); + index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of("j", macs("AA:AA:AA:AA:AA:01"))), 0L); + index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of("j", macs("AA:AA:AA:AA:AA:99"))), 1L); + + assertEquals(1, index.size()); + assertNull("a rolled-past address must stop matching", index.matchFor("AA:AA:AA:AA:AA:01")); + assertEquals(KEYS, index.matchFor("AA:AA:AA:AA:AA:99").getBeaconId()); + } + + /** + * A tag whose accessory JSON has not been backfilled yet resolves to nothing. It must cost + * only its own sightings, not the whole rebuild. + */ + @Test + public void oneUnresolvableTagDoesNotCostTheOthers() { + final Map tags = new HashMap<>(); + tags.put(KEYS, "good"); + tags.put(BIKE, "unbackfilled"); + + final NearbyTagIndex index = new NearbyTagIndex(); + index.rebuild(tags, resolverFor(Map.of("good", macs("AA:AA:AA:AA:AA:01"))), 0L); + + assertEquals(KEYS, index.matchFor("AA:AA:AA:AA:AA:01").getBeaconId()); + assertEquals(1, index.size()); + } + + /** + * The resolver's documented way of saying "not from me": null, not an empty map. + * + *

This is the one that got out. Turning on "show my own Apple devices" put a + * phone in the list, and a phone has no rolling-key alignment, so the resolver refused it - + * correctly. The refusal was then dereferenced, the throw killed the whole rebuild, and the + * scan never started: every real tag stopped being seen, with nothing failing anywhere to + * say why. One entry may only ever cost its own sightings. + */ + @Test + public void aTagTheResolverRefusesDoesNotCostTheOthers() { + final Map tags = new HashMap<>(); + tags.put(KEYS, "good"); + tags.put(BIKE, "refused"); + + final AccessoryMacResolver refusesOne = json -> + "good".equals(json) ? macs("AA:AA:AA:AA:AA:01") : null; + + final NearbyTagIndex index = new NearbyTagIndex(); + index.rebuild(tags, refusesOne, 0L); + + assertEquals(KEYS, index.matchFor("AA:AA:AA:AA:AA:01").getBeaconId()); + assertEquals(1, index.size()); + } + + /** And when every tag is refused, that is an empty index rather than a thrown rebuild. */ + @Test + public void aResolverThatRefusesEverythingLeavesAnEmptyIndexRatherThanThrowing() { + final NearbyTagIndex index = new NearbyTagIndex(); + + index.rebuild(twoTags(), json -> null, 5_000L); + + assertEquals(0, index.size()); + assertFalse("a rebuild that ran must count as built, or it repeats every scan result", + index.isStale(5_000L)); + } + + /** + * The index the address was derived at is carried, not discarded. + * + *

It is the hint that lets the alignment correction check one index instead of + * re-deriving a 48-hour window - three key derivations against about 1150. Dropping it here + * is what made that correction expensive enough to get the app killed for not answering + * input, and nothing would have failed to say so. + */ + @Test + public void eachAddressRemembersTheIndexItWasDerivedAt() { + final Map byMac = new HashMap<>(); + byMac.put("AA:AA:AA:AA:AA:01", 6221); + byMac.put("AA:AA:AA:AA:AA:02", 6222); + + final NearbyTagIndex index = new NearbyTagIndex(); + index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of("j", byMac)), 0L); + + assertEquals(Integer.valueOf(6221), index.matchFor("AA:AA:AA:AA:AA:01").getKeyIndex()); + assertEquals(Integer.valueOf(6222), index.matchFor("AA:AA:AA:AA:AA:02").getKeyIndex()); + } + + @Test + public void resolvesEachTagExactlyOncePerRebuild() { + final AtomicInteger calls = new AtomicInteger(); + final AccessoryMacResolver counting = json -> { + calls.incrementAndGet(); + return Map.of(); + }; + + new NearbyTagIndex().rebuild(twoTags(), counting, 0L); + + assertEquals("one interpreter start per tag, not per address", 2, calls.get()); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagLabelTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagLabelTest.java new file mode 100644 index 00000000..9aba6d4c --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagLabelTest.java @@ -0,0 +1,62 @@ +package dev.wander.android.opentagviewer.ble; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import dev.wander.android.opentagviewer.R; + +/** A JVM test: {@link NearbyTagLabel} chooses resources without touching a {@code Context}. */ +public class NearbyTagLabelTest { + + @Test + public void shortBatteryLabelPicksTheMatchingWord() { + assertEquals(R.string.battery_short_full, + NearbyTagLabel.shortBatteryLabel(FindMyAdvertisement.BatteryLevel.FULL)); + assertEquals(R.string.battery_short_medium, + NearbyTagLabel.shortBatteryLabel(FindMyAdvertisement.BatteryLevel.MEDIUM)); + assertEquals(R.string.battery_short_low, + NearbyTagLabel.shortBatteryLabel(FindMyAdvertisement.BatteryLevel.LOW)); + assertEquals(R.string.battery_short_very_low, + NearbyTagLabel.shortBatteryLabel(FindMyAdvertisement.BatteryLevel.VERY_LOW)); + } + + @Test + public void aStrongSignalFillsAllFiveDots() { + assertEquals(5, NearbyTagLabel.signalStrengthLevel(-50)); + assertEquals("●●●●●", NearbyTagLabel.signalStrengthBars(-50)); + } + + @Test + public void aFaintSignalFillsOnlyOneDot() { + assertEquals(1, NearbyTagLabel.signalStrengthLevel(-95)); + assertEquals("●○○○○", NearbyTagLabel.signalStrengthBars(-95)); + } + + @Test + public void neverFillsZeroDots() { + // A sighting existing at all means some signal was heard, however faint. + assertEquals(1, NearbyTagLabel.signalStrengthLevel(-200)); + } + + @Test + public void barsAlwaysHaveFiveDotsTotal() { + for (int rssi = -100; rssi <= -40; rssi++) { + assertEquals("rssi=" + rssi, 5, NearbyTagLabel.signalStrengthBars(rssi).length()); + } + } + + @Test + public void aStrongerReadingNeverRanksBelowAWeakerOne() { + // The whole point of showing this at all: as a reading improves while someone moves, + // the dot count must not go backwards. + final int[] fromWeakToStrong = {-95, -85, -84, -75, -74, -65, -64, -55, -54, -50}; + int previousLevel = 0; + for (final int rssi : fromWeakToStrong) { + final int level = NearbyTagLabel.signalStrengthLevel(rssi); + assertTrue("rssi=" + rssi + " ranked below a weaker reading", level >= previousLevel); + previousLevel = level; + } + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagSightingsTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagSightingsTest.java new file mode 100644 index 00000000..2ed06007 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagSightingsTest.java @@ -0,0 +1,89 @@ +package dev.wander.android.opentagviewer.ble; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +import dev.wander.android.opentagviewer.ble.FindMyAdvertisement.BatteryLevel; +import dev.wander.android.opentagviewer.ble.FindMyAdvertisement.State; + +/** A JVM test: the ageing rule is what matters here, and it needs no radio to check. */ +public class NearbyTagSightingsTest { + + private static final String KEYS = "keys-beacon-id"; + private static final String BIKE = "bike-beacon-id"; + + private static NearbyTagSighting seen(final String beaconId, final long atMs) { + return new NearbyTagSighting(beaconId, 4321, -50, BatteryLevel.FULL, 0x00, State.SEPARATED, atMs); + } + + @Test + public void aTagNeverSeenHasNoSighting() { + assertNull(new NearbyTagSightings().freshFor(KEYS, 0L)); + } + + @Test + public void aJustSeenTagIsReported() { + final NearbyTagSightings sightings = new NearbyTagSightings(); + sightings.record(seen(KEYS, 1_000L)); + + final NearbyTagSighting fresh = sightings.freshFor(KEYS, 1_000L); + assertNotNull(fresh); + assertEquals(KEYS, fresh.getBeaconId()); + } + + /** + * The claim has to expire on its own. Nothing tells us a tag left; we just stop + * hearing it. A badge that stays would read "nearby" for a tag already down the road. + */ + @Test + public void aSightingStopsCountingOnceItIsTooOld() { + final NearbyTagSightings sightings = new NearbyTagSightings(); + sightings.record(seen(KEYS, 0L)); + + assertNotNull(sightings.freshFor(KEYS, NearbyTagSightings.FRESH_FOR_MS - 1)); + assertNull(sightings.freshFor(KEYS, NearbyTagSightings.FRESH_FOR_MS)); + } + + @Test + public void beingSeenAgainRenewsIt() { + final NearbyTagSightings sightings = new NearbyTagSightings(); + sightings.record(seen(KEYS, 0L)); + sightings.record(seen(KEYS, 20_000L)); + + assertNotNull("the later sighting should carry it past the first one's expiry", + sightings.freshFor(KEYS, 40_000L)); + } + + @Test + public void theLatestSightingWins() { + final NearbyTagSightings sightings = new NearbyTagSightings(); + sightings.record(new NearbyTagSighting(KEYS, 4321, -90, BatteryLevel.FULL, 0x00, State.SEPARATED, 0L)); + sightings.record(new NearbyTagSighting(KEYS, 4321, -40, BatteryLevel.LOW, 0x80, State.SEPARATED, 100L)); + + final NearbyTagSighting fresh = sightings.freshFor(KEYS, 100L); + assertEquals(-40, fresh.getRssi()); + assertEquals(BatteryLevel.LOW, fresh.getBatteryLevel()); + } + + @Test + public void tagsAreTrackedIndependently() { + final NearbyTagSightings sightings = new NearbyTagSightings(); + sightings.record(seen(KEYS, 0L)); + + assertNotNull(sightings.freshFor(KEYS, 0L)); + assertNull(sightings.freshFor(BIKE, 0L)); + } + + /** Scanning has stopped, so nothing on screen may keep claiming to be current. */ + @Test + public void clearingDropsEverything() { + final NearbyTagSightings sightings = new NearbyTagSightings(); + sightings.record(seen(KEYS, 0L)); + sightings.clear(); + + assertNull(sightings.freshFor(KEYS, 0L)); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcherTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcherTest.java new file mode 100644 index 00000000..3ce50b4d --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcherTest.java @@ -0,0 +1,168 @@ +package dev.wander.android.opentagviewer.ble; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Test; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import dev.wander.android.opentagviewer.python.AccessoryMacResolver; + +/** + * Covers {@link NearbyTagWatcher#maybeNotifySightingListener} through the package-private + * constructor - no radio, no Android, an injected clock. The scan itself needs a real adapter + * and is not exercised here; see {@link NearbyTagWatcher#sightingFrom} for what a JVM test can + * reach of the scan side. + * + *

The listener always fires on {@code Schedulers.io()}, deliberately - see the method's own + * doc - so every test here waits on a latch rather than asserting immediately after the call. + */ +public class NearbyTagWatcherTest { + + private static final String BEACON_ID = "keys-beacon-id"; + private static final String MAC = "AA:BB:CC:DD:EE:FF"; + private static final long AWAIT_SECONDS = 5; + + private static AccessoryMacResolver anyResolver() { + return json -> Map.of(); + } + + /** A sighting as the scan callback would have built one, for a tag reporting a full battery. */ + private static NearbyTagSighting sightingOf(final String beaconId) { + return sightingOf(beaconId, FindMyAdvertisement.BatteryLevel.FULL, 0b0000_0000); + } + + private static NearbyTagSighting sightingOf( + final String beaconId, + final FindMyAdvertisement.BatteryLevel level, + final int statusByte) { + return new NearbyTagSighting(beaconId, 4321, -60, level, statusByte, + FindMyAdvertisement.State.SEPARATED, 1_700_000_000_000L); + } + + /** Records each call and counts down a latch, so a test can wait for the async dispatch. */ + private static final class RecordingListener implements NearbyTagWatcher.SightingListener { + final List calls = new CopyOnWriteArrayList<>(); + final List sightings = new CopyOnWriteArrayList<>(); + private final CountDownLatch latch; + + RecordingListener(final int expectedCalls) { + this.latch = new CountDownLatch(expectedCalls); + } + + @Override + public void onSighting(final NearbyTagSighting sighting, final String mac) { + this.calls.add(sighting.getBeaconId()); + this.sightings.add(sighting); + this.latch.countDown(); + } + + /** Waits for the expected call count, then gives a little more time to catch extras. */ + void awaitThenSettle() throws InterruptedException { + if (!this.latch.await(AWAIT_SECONDS, TimeUnit.SECONDS)) { + fail("expected call(s) never arrived within " + AWAIT_SECONDS + "s"); + } + Thread.sleep(100); + } + } + + private static NearbyTagWatcher watcherWith( + final NearbyTagWatcher.SightingListener listener, final long[] clockMs) { + return new NearbyTagWatcher( + anyResolver(), listener, 0, new NearbyTagIndex(), () -> clockMs[0]); + } + + @Test + public void notifiesTheListenerOnAMatchedSighting() throws InterruptedException { + final RecordingListener listener = new RecordingListener(1); + final long[] clock = {0L}; + final NearbyTagWatcher watcher = watcherWith(listener, clock); + + watcher.maybeNotifySightingListener(sightingOf(BEACON_ID), MAC); + + listener.awaitThenSettle(); + assertEquals(1, listener.calls.size()); + } + + @Test + public void throttlesRepeatedCallsForTheSameBeacon() throws InterruptedException { + final RecordingListener listener = new RecordingListener(1); + final long[] clock = {0L}; + final NearbyTagWatcher watcher = watcherWith(listener, clock); + + watcher.maybeNotifySightingListener(sightingOf(BEACON_ID), MAC); + clock[0] = NearbyTagWatcher.SIGHTING_LISTENER_INTERVAL_MS - 1; + watcher.maybeNotifySightingListener(sightingOf(BEACON_ID), MAC); + + listener.awaitThenSettle(); + assertEquals("the second call landed inside the throttle window", 1, listener.calls.size()); + } + + @Test + public void callsAgainOnceTheThrottleWindowHasPassed() throws InterruptedException { + final RecordingListener listener = new RecordingListener(2); + final long[] clock = {0L}; + final NearbyTagWatcher watcher = watcherWith(listener, clock); + + watcher.maybeNotifySightingListener(sightingOf(BEACON_ID), MAC); + clock[0] = NearbyTagWatcher.SIGHTING_LISTENER_INTERVAL_MS; + watcher.maybeNotifySightingListener(sightingOf(BEACON_ID), MAC); + + listener.awaitThenSettle(); + assertEquals(2, listener.calls.size()); + } + + @Test + public void aNullListenerIsSimplySkipped() { + final NearbyTagWatcher watcher = new NearbyTagWatcher( + anyResolver(), null, 0, new NearbyTagIndex(), () -> 0L); + + // Must not throw. + watcher.maybeNotifySightingListener(sightingOf(BEACON_ID), MAC); + } + + /** + * The listener is handed the whole sighting, because the same advertisement feeds two + * different writes: the address corrects key alignment, and the battery level is kept for + * long after the tag has gone quiet. A listener given only an address could not do the + * second, and a second listener for it would fire on its own schedule rather than this + * one's throttle. + */ + @Test + public void handsOverWhatTheAdvertisementSaidNotJustWhereItCameFrom() + throws InterruptedException { + final RecordingListener listener = new RecordingListener(1); + final long[] clock = {0L}; + final NearbyTagWatcher watcher = watcherWith(listener, clock); + + watcher.maybeNotifySightingListener( + sightingOf(BEACON_ID, FindMyAdvertisement.BatteryLevel.LOW, 0b1000_0000), MAC); + + listener.awaitThenSettle(); + assertEquals(1, listener.sightings.size()); + assertEquals(FindMyAdvertisement.BatteryLevel.LOW, + listener.sightings.get(0).getBatteryLevel()); + assertEquals("the raw status byte must survive the hand-over too", + 0b1000_0000, listener.sightings.get(0).getStatusByte()); + } + + @Test + public void eachBeaconIsThrottledIndependently() throws InterruptedException { + final RecordingListener listener = new RecordingListener(2); + final long[] clock = {0L}; + final NearbyTagWatcher watcher = watcherWith(listener, clock); + + watcher.maybeNotifySightingListener(sightingOf(BEACON_ID), MAC); + watcher.maybeNotifySightingListener(sightingOf("bike-beacon-id"), "11:22:33:44:55:66"); + + listener.awaitThenSettle(); + assertTrue("a busy tag must not starve another tag's correction", + listener.calls.contains(BEACON_ID) && listener.calls.contains("bike-beacon-id")); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/WideningSearchTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/WideningSearchTest.java new file mode 100644 index 00000000..8b373a35 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/WideningSearchTest.java @@ -0,0 +1,255 @@ +package dev.wander.android.opentagviewer.ble; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import androidx.annotation.Nullable; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import dev.wander.android.opentagviewer.python.AccessoryMacResolver; + +/** + * The rule for looking further back, without a radio, a phone or a key derivation. + * + *

What matters here is not that it derives, but when it refuses to: while the app is still + * starting up, for a tag that was just heard, past the point it is willing to look, and after a + * derivation that failed. Each of those, got wrong, is either wasted battery or a range recorded + * as covered that nothing ever looked at. + */ +public class WideningSearchTest { + + private static final String NEAR = "TAG-THAT-IS-HERE"; + private static final String MISSING = "TAG-NOBODY-HAS-HEARD"; + private static final String JSON = "{\"accessory\":true}"; + + @Rule + public TemporaryFolder files = new TemporaryFolder(); + + private DerivedAddressStore store; + private RecordingResolver resolver; + private WideningSearch search; + + /** Records what it was asked for, and answers with addresses named after the range. */ + private static final class RecordingResolver implements AccessoryMacResolver { + private final List derivedRanges = new ArrayList<>(); + private int windowHi = 10_000; + private boolean deriveNothing = false; + private boolean noWindow = false; + + @Override + public Map currentMacAddresses(final String accessoryJson) { + return Map.of(); + } + + @Override + @Nullable + public IndexRange candidateWindow(final String accessoryJson) { + return this.noWindow ? null : new IndexRange(this.windowHi - 383, this.windowHi); + } + + @Override + public Map addressesBetween( + final String accessoryJson, final int lo, final int hi) { + this.derivedRanges.add(new int[]{lo, hi}); + + if (this.deriveNothing) { + return Map.of(); + } + + final Map out = new HashMap<>(); + for (int i = lo; i <= hi; i++) { + out.put(String.format("AA:BB:CC:%02X:%02X:%02X", + (i >> 16) & 0xFF, (i >> 8) & 0xFF, i & 0xFF), i); + } + return out; + } + } + + @Before + public void setUp() { + this.store = new DerivedAddressStore(this.files.getRoot()); + this.resolver = new RecordingResolver(); + this.search = new WideningSearch(this.resolver, this.store); + } + + /** A tag whose derived range starts at {@code lo} and reaches the top of the window. */ + private void alreadyDerived(final String beaconId, final int lo) { + final Map addresses = new HashMap<>(); + addresses.put("AA:BB:CC:DD:EE:FF", lo); + this.store.save(beaconId, lo, 10_000, addresses); + } + + private static Map tags(final String... beaconIds) { + final Map out = new HashMap<>(); + for (final String beaconId : beaconIds) { + out.put(beaconId, JSON); + } + return out; + } + + @Test + public void nothingIsDueBeforeTheWatchHasEvenStarted() { + assertFalse(this.search.isDue(1_000_000L)); + } + + @Test + public void nothingIsDueDuringTheWarmUp() { + this.search.started(0L); + + assertFalse("deriving during startup is the one thing this must not do", + this.search.isDue(WideningSearch.WARM_UP_MS - 1)); + } + + @Test + public void aRoundIsDueOnceTheWarmUpHasPassed() { + this.search.started(0L); + + assertTrue(this.search.isDue(WideningSearch.WARM_UP_MS)); + } + + @Test + public void roundsAreSpacedOut() { + this.search.started(0L); + final long first = WideningSearch.WARM_UP_MS; + + this.search.widenOne(tags(MISSING), Map.of(), first); + + assertFalse(this.search.isDue(first + WideningSearch.BETWEEN_ROUNDS_MS - 1)); + assertTrue(this.search.isDue(first + WideningSearch.BETWEEN_ROUNDS_MS)); + } + + @Test + public void aTagHeardJustNowIsLeftAlone() { + this.alreadyDerived(NEAR, 9_000); + + final Map heard = new HashMap<>(); + heard.put(NEAR, 500_000L); + + assertNull(this.search.widenOne(tags(NEAR), heard, 500_000L + 1000L)); + assertTrue(this.resolver.derivedRanges.isEmpty()); + } + + @Test + public void aTagNotHeardForLongEnoughIsWidened() { + this.alreadyDerived(MISSING, 9_000); + + final Map heard = new HashMap<>(); + heard.put(MISSING, 0L); + + assertEquals(MISSING, + this.search.widenOne(tags(MISSING), heard, WideningSearch.HEARD_RECENTLY_MS)); + + assertEquals(1, this.resolver.derivedRanges.size()); + assertEquals(9_000 - WideningSearch.CHUNK_INDICES, this.resolver.derivedRanges.get(0)[0]); + assertEquals(8_999, this.resolver.derivedRanges.get(0)[1]); + } + + @Test + public void aTagNeverHeardAtAllIsWidened() { + this.alreadyDerived(MISSING, 9_000); + + assertEquals(MISSING, this.search.widenOne(tags(MISSING), Map.of(), 1_000_000L)); + } + + @Test + public void theStoredRangeGrowsDownwardAndKeepsWhatItHad() { + this.alreadyDerived(MISSING, 9_000); + + this.search.widenOne(tags(MISSING), Map.of(), 1_000_000L); + + final DerivedAddressStore.Derived held = this.store.load(MISSING); + assertNotNull(held); + assertEquals(9_000 - WideningSearch.CHUNK_INDICES, held.getLo()); + assertEquals(10_000, held.getHi()); + assertTrue("what was already held must survive", + held.getAddresses().containsKey("AA:BB:CC:DD:EE:FF")); + } + + @Test + public void roundsResumeWhereTheLastOneStopped() { + this.alreadyDerived(MISSING, 9_000); + + this.search.widenOne(tags(MISSING), Map.of(), 1_000_000L); + this.search.widenOne(tags(MISSING), Map.of(), 2_000_000L); + + assertEquals(2, this.resolver.derivedRanges.size()); + assertEquals(9_000 - 2 * WideningSearch.CHUNK_INDICES, + this.resolver.derivedRanges.get(1)[0]); + assertEquals(9_000 - WideningSearch.CHUNK_INDICES - 1, + this.resolver.derivedRanges.get(1)[1]); + } + + @Test + public void itStopsAtTheFloorRatherThanRunningToZero() { + final int floor = 10_000 - WideningSearch.TARGET_INDICES; + this.alreadyDerived(MISSING, floor + 10); + + assertEquals(MISSING, this.search.widenOne(tags(MISSING), Map.of(), 1_000_000L)); + assertEquals(floor, this.store.load(MISSING).getLo()); + + assertNull("already as far back as it will look", + this.search.widenOne(tags(MISSING), Map.of(), 2_000_000L)); + } + + @Test + public void aTagWithNothingDerivedYetIsLeftToTheOrdinaryRebuild() { + assertNull(this.search.widenOne(tags(MISSING), Map.of(), 1_000_000L)); + assertTrue(this.resolver.derivedRanges.isEmpty()); + } + + @Test + public void anUnreadableAccessoryIsSkipped() { + this.alreadyDerived(MISSING, 9_000); + this.resolver.noWindow = true; + + assertNull(this.search.widenOne(tags(MISSING), Map.of(), 1_000_000L)); + } + + /** + * The failure that would not announce itself: recording indices as covered that were never + * derived means nothing ever goes back for them, and the tag stays unfindable for a reason + * no log would show. + */ + @Test + public void aFailedDerivationDoesNotAdvanceTheStoredRange() { + this.alreadyDerived(MISSING, 9_000); + this.resolver.deriveNothing = true; + + assertNull(this.search.widenOne(tags(MISSING), Map.of(), 1_000_000L)); + assertEquals(9_000, this.store.load(MISSING).getLo()); + } + + @Test + public void onlyOneTagIsWidenedPerRound() { + this.alreadyDerived(NEAR, 9_000); + this.alreadyDerived(MISSING, 9_000); + + this.search.widenOne(tags(NEAR, MISSING), Map.of(), 1_000_000L); + + assertEquals("a round must not cost more because somebody owns more tags", + 1, this.resolver.derivedRanges.size()); + } + + @Test + public void theTagsWorthWideningAreTheOnesNotHeardRecently() { + final Map heard = new HashMap<>(); + heard.put(NEAR, 1_000_000L); + heard.put(MISSING, 1_000_000L - WideningSearch.HEARD_RECENTLY_MS); + + assertEquals(java.util.Set.of(MISSING), + WideningSearch.notHeardRecently( + java.util.Set.of(NEAR, MISSING), heard, 1_000_000L)); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/LeftBehindTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/LeftBehindTest.java new file mode 100644 index 00000000..28703c81 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/LeftBehindTest.java @@ -0,0 +1,76 @@ +package dev.wander.android.opentagviewer.util; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * The rule behind "you are leaving without your keys". + * + *

An alert that fires when it should not is worse than none: people turn off a feature that + * cries wolf, and then it is not there on the day it matters. So the rule is deliberately hard + * to satisfy - the tag must have gone quiet and the phone must have gone somewhere else. + */ +public class LeftBehindTest { + + /** The cafe. */ + private static final double LAT = 49.4767; + private static final double LON = 8.5622; + + private static final long NOON = 1_700_000_000_000L; + private static final long WELL_PAST = NOON + LeftBehind.QUIET_FOR_MS + 1; + + /** Roughly 220 metres north, which is past the threshold. */ + private static final double FAR_LAT = LAT + 0.002; + + @Test + public void quietAndFarAwayIsLeftBehind() { + assertTrue(LeftBehind.looksLeftBehind(NOON, LAT, LON, WELL_PAST, FAR_LAT, LON)); + } + + /** + * Silence alone is not enough, and this is the case that matters. A tag in a pocket + * with a body between it and the phone misses scan windows, and the background scan uses the + * cheapest mode the platform offers. Alerting on that would fire on every walk. + */ + @Test + public void quietButStillInTheSamePlaceIsNotLeftBehind() { + assertFalse(LeftBehind.looksLeftBehind(NOON, LAT, LON, WELL_PAST, LAT, LON)); + } + + /** Moving away while the tag is still being heard means it came along. */ + @Test + public void farAwayButHeardRecentlyIsNotLeftBehind() { + assertFalse(LeftBehind.looksLeftBehind( + NOON, LAT, LON, NOON + LeftBehind.QUIET_FOR_MS - 1, FAR_LAT, LON)); + } + + @Test + public void aTagThisPhoneHasNeverHeardIsNotLeftBehind() { + assertFalse(LeftBehind.looksLeftBehind(null, null, null, WELL_PAST, FAR_LAT, LON)); + } + + /** + * Without a position for the last sighting there is no way to tell moving away from standing + * still, and silence on its own does not earn an alert. + */ + @Test + public void withoutAPositionForTheLastSightingNothingIsClaimed() { + assertFalse(LeftBehind.looksLeftBehind(NOON, null, null, WELL_PAST, FAR_LAT, LON)); + } + + /** A few metres of GPS wobble is not going somewhere else. */ + @Test + public void gpsWobbleIsNotMovingAway() { + assertFalse(LeftBehind.looksLeftBehind( + NOON, LAT, LON, WELL_PAST, LAT + 0.00005, LON)); + } + + /** The threshold is well past Bluetooth range, so it cannot fire from the same room. */ + @Test + public void theDistanceThresholdIsWellPastBluetoothRange() { + assertTrue("a tag would still be audible at this range", + LeftBehind.MOVED_AWAY_METRES > 50.0); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/LocalFixWorthKeepingTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/LocalFixWorthKeepingTest.java new file mode 100644 index 00000000..ba6f35fa --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/LocalFixWorthKeepingTest.java @@ -0,0 +1,88 @@ +package dev.wander.android.opentagviewer.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * The rule that keeps a locally heard position from flooding the history. + * + *

Sightings arrive far faster than positions are worth keeping: a tag in range is heard every + * second or two, and even the throttled callback fires once a minute. Writing a row each time + * would put several hundred identical points into a tag's day, each one reverse-geocoded when + * shown. + */ +public class LocalFixWorthKeepingTest { + + /** Ilvesheim, where the measurements behind this feature were taken. */ + private static final double LAT = 49.4767; + private static final double LON = 8.5622; + + private static final long NOON = 1_700_000_000_000L; + + @Test + public void theFirstFixForATagIsAlwaysKept() { + assertTrue(LocalFixWorthKeeping.worthKeeping(null, null, null, LAT, LON, NOON)); + } + + @Test + public void standingStillDoesNotWriteAgainStraightAway() { + assertFalse("a tag beside somebody must not write a row per sighting", + LocalFixWorthKeeping.worthKeeping(LAT, LON, NOON, LAT, LON, NOON + 60_000)); + } + + @Test + public void standingStillIsWorthRecordingAgainEventually() { + assertTrue("still here an hour later is information a history should carry", + LocalFixWorthKeeping.worthKeeping( + LAT, LON, NOON, LAT, LON, NOON + LocalFixWorthKeeping.AGAIN_AFTER_MS)); + } + + /** + * Roughly 90 metres north, which is past the threshold: a different place. + */ + @Test + public void movingFarEnoughWritesAgainImmediately() { + assertTrue(LocalFixWorthKeeping.worthKeeping( + LAT, LON, NOON, LAT + 0.0008, LON, NOON + 1_000)); + } + + /** + * Roughly 5 metres, which is inside GPS noise standing still - two rows here would differ + * only by the fix wobbling, not by anything having happened. + */ + @Test + public void aFixThatOnlyWobbledIsNotADifferentPlace() { + assertFalse(LocalFixWorthKeeping.worthKeeping( + LAT, LON, NOON, LAT + 0.000045, LON, NOON + 1_000)); + } + + @Test + public void distanceIsMeasuredOnTheGlobeRatherThanTheGrid() { + // A hundredth of a degree of latitude is about 1.11 km anywhere on Earth. + final double metres = LocalFixWorthKeeping.metresBetween(LAT, LON, LAT + 0.01, LON); + + assertEquals(1110.0, metres, 10.0); + } + + /** + * A degree of longitude shrinks toward the poles. A flat approximation without the cosine + * correction gets this wrong by more the further north the user lives, which is a bug that + * would never be reported by anybody near the equator. + */ + @Test + public void aDegreeOfLongitudeIsShorterThisFarNorth() { + final double eastWest = LocalFixWorthKeeping.metresBetween(LAT, LON, LAT, LON + 0.01); + final double northSouth = LocalFixWorthKeeping.metresBetween(LAT, LON, LAT + 0.01, LON); + + assertTrue("east-west (" + eastWest + "m) must be shorter than north-south (" + + northSouth + "m) at 49 degrees north", eastWest < northSouth * 0.7); + } + + @Test + public void theSamePointIsZeroMetresApart() { + assertEquals(0.0, LocalFixWorthKeeping.metresBetween(LAT, LON, LAT, LON), 0.0001); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/android/CachedPhoneLocationTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/android/CachedPhoneLocationTest.java new file mode 100644 index 00000000..c6848ea5 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/android/CachedPhoneLocationTest.java @@ -0,0 +1,110 @@ +package dev.wander.android.opentagviewer.util.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * The cache that stops the location indicator blinking on every sighting. + * + *

Android lights the indicator whenever an app touches location, and the sighting path + * touches it per sighting, per tag, twice - once to place the sighting and once for the + * left-behind rule. With the background service running that is a chip flashing every few + * seconds, which reads as the app tracking somebody far harder than it does. + */ +public class CachedPhoneLocationTest { + + private static final double LAT = 49.4767; + private static final double LON = 8.5622; + + /** Counts reads, so "how often did this touch location" is the thing being asserted. */ + private static final class CountingLocation implements PhoneLocation { + private final AtomicInteger reads = new AtomicInteger(); + private Fix answer; + + private CountingLocation(final Fix answer) { + this.answer = answer; + } + + @Override + public Fix lastKnown() { + this.reads.incrementAndGet(); + return this.answer; + } + } + + @Test + public void repeatedCallsTouchLocationOnce() { + final CountingLocation real = new CountingLocation(new PhoneLocation.Fix(LAT, LON, 8)); + final long[] clock = {1_000L}; + final CachedPhoneLocation cached = new CachedPhoneLocation(real, () -> clock[0]); + + cached.lastKnown(); + cached.lastKnown(); + cached.lastKnown(); + + assertEquals("three sightings must not be three location accesses", 1, real.reads.get()); + } + + @Test + public void theCacheExpires() { + final CountingLocation real = new CountingLocation(new PhoneLocation.Fix(LAT, LON, 8)); + final long[] clock = {1_000L}; + final CachedPhoneLocation cached = new CachedPhoneLocation(real, () -> clock[0]); + + cached.lastKnown(); + clock[0] += CachedPhoneLocation.FRESH_FOR_MS; + cached.lastKnown(); + + assertEquals(2, real.reads.get()); + } + + /** + * The correction that makes caching honest. A minute-old position handed back at the + * accuracy of a fresh fix would be drawn on the map as a tight circle around somewhere the + * phone no longer is. Walking pace times the age is the width it can still claim. + */ + @Test + public void aCachedFixReportsTheAccuracyItCanStillClaim() { + final CountingLocation real = new CountingLocation(new PhoneLocation.Fix(LAT, LON, 8)); + final long[] clock = {1_000L}; + final CachedPhoneLocation cached = new CachedPhoneLocation(real, () -> clock[0]); + + cached.lastKnown(); + clock[0] += 30_000L; + final PhoneLocation.Fix stale = cached.lastKnown(); + + assertEquals("the position itself does not move", LAT, stale.getLatitude(), 0.000001); + assertTrue("thirty seconds of walking is tens of metres, and must be admitted", + stale.getAccuracyMetres() > 8 + 30); + } + + @Test + public void aFreshFixIsNotWidened() { + final CountingLocation real = new CountingLocation(new PhoneLocation.Fix(LAT, LON, 8)); + final long[] clock = {1_000L}; + + assertEquals(8, new CachedPhoneLocation(real, () -> clock[0]).lastKnown() + .getAccuracyMetres()); + } + + /** + * Having no fix is a state that lasts, so retrying it per sighting would light the indicator + * exactly as often as succeeding, for an answer that will not have changed. + */ + @Test + public void havingNoFixIsRememberedToo() { + final CountingLocation real = new CountingLocation(null); + final long[] clock = {1_000L}; + final CachedPhoneLocation cached = new CachedPhoneLocation(real, () -> clock[0]); + + assertNull(cached.lastKnown()); + assertNull(cached.lastKnown()); + + assertEquals(1, real.reads.get()); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/parse/AccessoryAlignmentTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/parse/AccessoryAlignmentTest.java new file mode 100644 index 00000000..a8f0f152 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/parse/AccessoryAlignmentTest.java @@ -0,0 +1,85 @@ +package dev.wander.android.opentagviewer.util.parse; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +import java.time.Instant; + +/** + * Reading the alignment a fetch left behind, out of FindMy.py's serialised accessory. + * + *

This is the value that decides whether the "Locating your tags" banner is telling the truth, + * and the one the debug panel shows. Both were reading the export's record instead, which never + * moves. + */ +public class AccessoryAlignmentTest { + + /** The shape FindMy.py's FindMyAccessory.to_json writes, trimmed to what is read here. */ + private static String accessory(final String alignmentDate, final String alignmentIndex) { + return "{\"type\":\"accessory\",\"master_key\":\"aa\",\"skn\":\"bb\",\"sks\":\"cc\"," + + "\"paired_at\":\"2024-03-11T09:00:00+00:00\",\"name\":\"Keys\"," + + "\"model\":\"AirTag\",\"identifier\":\"x\",\"group_identifier\":null," + + "\"serial_number\":\"HK7Q2M4XLPNV\"," + + "\"alignment_date\":" + alignmentDate + "," + + "\"alignment_index\":" + alignmentIndex + "}"; + } + + @Test + public void itreadsTheDateAndIndexAFetchWroteBack() { + final String json = accessory("\"2026-09-02T07:15:00+00:00\"", "51234"); + + assertEquals(Instant.parse("2026-09-02T07:15:00Z").toEpochMilli(), + (long) AccessoryAlignment.alignedAtMillis(json)); + assertEquals(Integer.valueOf(51234), AccessoryAlignment.alignedIndex(json)); + } + + /** + * FindMy.py writes {@code datetime.isoformat()}, which carries an offset rather than a Z. + * + *

{@code Instant.parse} rejects that, so a reader written against the obvious API would + * answer null for every real accessory and quietly reinstate the bug this fixes. + */ + @Test + public void anoffsetIsAcceptedAsWellAsZuluTime() { + final long withOffset = AccessoryAlignment.alignedAtMillis( + accessory("\"2026-09-02T09:15:00+02:00\"", "1")); + final long withZ = AccessoryAlignment.alignedAtMillis( + accessory("\"2026-09-02T07:15:00Z\"", "1")); + + assertEquals(withZ, withOffset); + } + + /** A tag imported and never fetched. Ordinary, and not an error. */ + @Test + public void anaccessoryThatHasNeverBeenAlignedAnswersNull() { + final String json = accessory("null", "null"); + + assertNull(AccessoryAlignment.alignedAtMillis(json)); + assertNull(AccessoryAlignment.alignedIndex(json)); + } + + /** Rows predating the FindMy 0.9.x upgrade carry no accessory JSON at all. */ + @Test + public void nothingAtAllAnswersNullRatherThanThrowing() { + assertNull(AccessoryAlignment.alignedAtMillis(null)); + assertNull(AccessoryAlignment.alignedIndex(null)); + assertNull(AccessoryAlignment.alignedAtMillis("")); + assertNull(AccessoryAlignment.alignedIndex(" ")); + } + + /** + * Unreadable input is unknown, not a crash. + * + *

This runs to decide whether to show a banner and to fill a debug row. Neither is worth + * failing a fetch over. + */ + @Test + public void rubbishIsUnknownRatherThanAFailure() { + assertNull(AccessoryAlignment.alignedAtMillis("not json")); + assertNull(AccessoryAlignment.alignedIndex("{\"alignment_index\":\"not a number\"}")); + assertNull(AccessoryAlignment.alignedAtMillis("{\"alignment_date\":\"yesterday\"}")); + assertNull(AccessoryAlignment.alignedAtMillis("{}")); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetchTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetchTest.java new file mode 100644 index 00000000..bbfc2bef --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetchTest.java @@ -0,0 +1,155 @@ +package dev.wander.android.opentagviewer.util.rx; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Which batches are worth warning somebody about, and which are not. + * + *

The banner used to go up for any fetch that took longer than six seconds, which on a slow + * network is nearly all of them. What makes a fetch genuinely long is how far back the key + * search has to start, and that is known before the request is sent - so these are the cases + * that decide it. + */ +public class SlowFirstFetchTest { + + private static final long NOW = 1_750_000_000_000L; + + private static long daysAgo(final int days) { + return NOW - TimeUnit.DAYS.toMillis(days); + } + + // ------------------------------------------------------------------ quick, so stay quiet + + @Test + public void aTagAlignedYesterdayIsOneRequestAndNeedsNoBanner() { + assertFalse("a day of keys is about 96, well inside one request", + SlowFirstFetch.isLikely(Collections.singletonList(daysAgo(1)), NOW)); + } + + @Test + public void aWholeBatchOfRecentlyAlignedTagsNeedsNoBanner() { + assertFalse("every one of them resumes near where it is now", + SlowFirstFetch.isLikely( + Arrays.asList(daysAgo(1), daysAgo(3), daysAgo(6)), NOW)); + } + + @Test + public void nothingToFetchIsNotSlow() { + assertFalse("an empty batch cannot take minutes", + SlowFirstFetch.isLikely(Collections.emptyList(), NOW)); + } + + /** The boundary itself, stated explicitly so a change to it has to be deliberate. */ + @Test + public void exactlyAtTheThresholdIsStillQuick() { + assertFalse("seven days is about three requests, which finishes while you watch", + SlowFirstFetch.isLikely( + Collections.singletonList(NOW - SlowFirstFetch.STALE_AFTER_MS), NOW)); + } + + // ------------------------------------------------------------------ slow, so say so + + @Test + public void aTagWithNoAlignmentRecordAtAllIsTheSlowestCase() { + assertTrue("with no record it starts at index 0 from the pairing date", + SlowFirstFetch.isLikely(Collections.singletonList(null), NOW)); + } + + @Test + public void aTagAlignedAMonthAgoIsWorthWarningAbout() { + assertTrue("about 2,880 keys, so roughly ten sequential requests", + SlowFirstFetch.isLikely(Collections.singletonList(daysAgo(30)), NOW)); + } + + /** + * One slow tag is enough, and this is the case the whole change turns on. + * + *

The batch is fetched one accessory at a time, so a single unaligned tag holds up every + * tag behind it. A rule of "most of them are quick" would stay silent through exactly the + * three-minute wait the banner exists for. + */ + @Test + public void oneUnalignedTagAmongManyQuickOnesIsStillSlow() { + final List batch = Arrays.asList(daysAgo(1), null, daysAgo(2)); + + assertTrue("the unaligned one holds up the two behind it", + SlowFirstFetch.isLikely(batch, NOW)); + } + + @Test + public void knowingNothingAboutTheBatchWarnsRatherThanStaysSilent() { + assertTrue("silence during a three-minute hang is the failure being avoided", + SlowFirstFetch.isLikely(null, NOW)); + } + + /** + * A clock that has gone backwards - a device whose time was wrong and got corrected - must + * not read as "aligned in the future, so very fresh" and suppress the banner forever. + */ + @Test + public void anAlignmentInTheFutureIsTreatedAsFresh() { + assertFalse("a future timestamp is nonsense, but it is not evidence of a long search", + SlowFirstFetch.isLikely( + Collections.singletonList(NOW + TimeUnit.DAYS.toMillis(2)), NOW)); + } + + /** + * The bug this pair of methods exists to stop. + * + *

A tag exported a month ago and fetched this morning is not a slow fetch, but the export's + * record still says a month. Reading only the record showed the banner on every single + * refresh, for anybody whose export was more than a week old, however healthy their tags. + */ + @Test + public void arecentFetchBeatsAnOldExportRecord() { + final long now = 1_700_000_000_000L; + final long thisMorning = now - TimeUnit.HOURS.toMillis(6); + final long aMonthAgo = now - TimeUnit.DAYS.toMillis(30); + + assertFalse("the export is old, but the keys are aligned to this morning", + SlowFirstFetch.isLikely( + List.of(SlowFirstFetch.laterOf(thisMorning, aMonthAgo)), now)); + } + + /** And a tag never fetched still leans on whatever the export knew. */ + @Test + public void theexportRecordStillCountsBeforeTheFirstFetch() { + final long now = 1_700_000_000_000L; + + assertEquals(Long.valueOf(now - TimeUnit.DAYS.toMillis(2)), + SlowFirstFetch.laterOf(null, now - TimeUnit.DAYS.toMillis(2))); + assertFalse(SlowFirstFetch.isLikely( + List.of(SlowFirstFetch.laterOf(null, now - TimeUnit.DAYS.toMillis(2))), now)); + } + + /** Neither known is the slowest case there is, and must stay slow. */ + @Test + public void neitherKnownIsStillSlow() { + final long now = 1_700_000_000_000L; + + assertNull(SlowFirstFetch.laterOf(null, null)); + assertTrue(SlowFirstFetch.isLikely( + Collections.singletonList(SlowFirstFetch.laterOf(null, null)), now)); + } + + /** A re-import can carry a newer record than a stale accessory blob. */ + @Test + public void afreshReimportBeatsAStaleAccessory() { + final long now = 1_700_000_000_000L; + final long yesterday = now - TimeUnit.DAYS.toMillis(1); + final long aYearAgo = now - TimeUnit.DAYS.toMillis(365); + + assertEquals(Long.valueOf(yesterday), SlowFirstFetch.laterOf(aYearAgo, yesterday)); + assertFalse(SlowFirstFetch.isLikely(List.of(SlowFirstFetch.laterOf(aYearAgo, yesterday)), now)); + } +} diff --git a/app/src/test/python/test_main.py b/app/src/test/python/test_main.py index facdff4a..d7550d56 100644 --- a/app/src/test/python/test_main.py +++ b/app/src/test/python/test_main.py @@ -19,6 +19,7 @@ import pytest import main +from findmy.keys import KeyPairType RESOURCES = Path(__file__).resolve().parents[1] / "resources" BEACON_PLISTS = sorted(RESOURCES.glob("*/OwnedBeacons/*.plist")) @@ -165,6 +166,363 @@ def test_json_that_is_not_an_accessory_is_refused(blob): main.accessoryFromJson(blob) +# -------------------------------------------------------------------------- +# currentMacAddresses +# +# The BLE MAC address(es) an accessory might currently be advertising - what +# dev.wander.android.opentagviewer.ble matches a scan result against to trigger an owned +# accessory's sound directly, without going through Apple's Find My network. +# -------------------------------------------------------------------------- + +_MAC_RE = re.compile(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$") + + +def test_current_mac_addresses_for_a_self_generated_tag(): + macs = main.currentMacAddresses(json.dumps(_CUSTOM_ACCESSORY)) + + assert macs is not None + assert len(macs) > 0 + for mac in macs: + assert _MAC_RE.match(mac), f"{mac!r} is not a MAC address" + + +def test_current_mac_addresses_is_deterministic_for_a_fixed_key_tag(): + """A self-generated tag's keys don't rotate, so asking twice must agree - unlike an Apple- + paired one, where this is only true at the exact same instant (rollover happens meanwhile).""" + once = main.currentMacAddresses(json.dumps(_CUSTOM_ACCESSORY)) + twice = main.currentMacAddresses(json.dumps(_CUSTOM_ACCESSORY)) + + assert once == twice + + +def test_current_mac_addresses_returns_none_on_garbage(): + """Failure must be None, not an exception - see AccessoryMacResolver's Java contract, which + reads None the same way as an empty answer: nothing to scan for yet.""" + assert main.currentMacAddresses("not an accessory at all") is None + + +def test_current_mac_addresses_refuses_an_unknown_accessory_type(): + assert main.currentMacAddresses( + json.dumps({"type": "something_from_the_future"})) is None + + +def _unaligned_accessory(paired_at: datetime) -> dict: + """An accessory that has never been aligned - what an owner's own Apple device looks like. + + A phone reaches this code through "show my own Apple devices"; it has no rolling-key + alignment record and never gains one, so its candidate window spans its whole life. + """ + from findmy import FindMyAccessory + + accessory = FindMyAccessory( + master_key=b"\x11" * 28, + skn=b"\x22" * 32, + sks=b"\x33" * 32, + paired_at=paired_at, + name="Something with no alignment", + ) + return accessory.to_json() + + +def test_current_mac_addresses_bounds_a_window_too_wide_to_derive(): + """Bounded rather than derived whole, and bounded *here*. + + The caller asks per accessory in a loop, and the derivation is blocking. Measured on a real + device that had been switched off: 39636 indices, over a year of keys - the loop never + reached the tags after it, so the scan never started and every real tag silently stopped + being seen. A caller cannot protect itself from this, because by the time it could measure + the answer the work is already done. + """ + stored = _unaligned_accessory(datetime.now(timezone.utc) - timedelta(days=400)) + + macs = main.currentMacAddresses(json.dumps(stored)) + + assert macs is not None and macs, "a never-aligned tag must still be scannable for" + spanned = max(macs.values()) - min(macs.values()) + assert spanned <= main._MAC_CANDIDATE_MAX_INDICES, ( + f"derived {spanned} indices, past the bound") + + +def test_current_mac_addresses_bounds_to_the_newest_indices(): + """The newest end, not the oldest. + + An accessory advertising right now has been running, so its index tracks the wall clock. + The bottom of an over-wide window belongs to a tag that was off for months, which is not + advertising at all - deriving that end would spend the whole budget where nothing can match. + """ + stored = _unaligned_accessory(datetime.now(timezone.utc) - timedelta(days=400)) + accessory = main.accessoryFromJson(json.dumps(stored)) + reachable_now = accessory.get_max_index( + datetime.now(timezone.utc) + main._MAC_CANDIDATE_MARGIN) + + macs = main.currentMacAddresses(json.dumps(stored)) + + assert max(macs.values()) >= reachable_now - 1, ( + "the newest reachable index must be inside the derived set") + + +def _freshly_aligned_accessory() -> dict: + """An accessory aligned as of now, which is what any tag the network found looks like.""" + from findmy import FindMyAccessory + + now = datetime.now(timezone.utc) + accessory = FindMyAccessory( + master_key=b"\x11" * 28, + skn=b"\x22" * 32, + sks=b"\x33" * 32, + paired_at=now - timedelta(days=200), + name="A tag the network found this morning", + alignment_date=now, + alignment_index=19200, + ) + return accessory.to_json() + + +def test_current_mac_addresses_still_answers_for_a_freshly_aligned_accessory(): + """The guard must not swallow the ordinary case it sits in front of. + + Note this one is paired 200 days ago: it is the *alignment* being current that keeps the + window narrow, not the tag being new. + """ + macs = main.currentMacAddresses(json.dumps(_freshly_aligned_accessory())) + + assert macs is not None + assert len(macs) > 0 + + +def test_current_mac_addresses_bounds_an_accessory_whose_alignment_went_stale(): + """Staleness is what sets the width, so an old alignment is bounded like none at all. + + Deriving such a window whole took 9 seconds at 30 days stale and two minutes at 400 on a + desktop, per the table on `_MAC_CANDIDATE_MAX_INDICES` - several times that under Chaquopy. + The bound keeps the tag scannable without paying for the part of the range that cannot be + live. + """ + stored = _paired_accessory(alignment_index=100) # aligned at a fixed date in the past + + macs = main.currentMacAddresses(json.dumps(stored)) + + assert macs is not None and macs + spanned = max(macs.values()) - min(macs.values()) + assert spanned <= main._MAC_CANDIDATE_MAX_INDICES + + +# -------------------------------------------------------------------------- +# recordAccessorySeen +# +# What keeps a wide currentMacAddresses margin from being paid for on every scan: a match +# against a *primary* key realigns the stored index, in either direction. A secondary key's +# index is only a lower bound and must not be trusted the same way. +# -------------------------------------------------------------------------- + +_ALIGNMENT_DATE = datetime(2026, 1, 1, tzinfo=timezone.utc) + + +def _paired_accessory(alignment_index: int) -> dict: + """A `FindMyAccessory` mapping with fixed, deterministic key material. + + Real master/session keys, so the derived MACs below are the actual ones a scan would see - + not a fake fixture standing in for them. Alignment is planted away from index 0 so a + correction has somewhere to move both above and below. + """ + from findmy import FindMyAccessory + + accessory = FindMyAccessory( + master_key=b"\x11" * 28, + skn=b"\x22" * 32, + sks=b"\x33" * 32, + paired_at=_ALIGNMENT_DATE, + name="Test tag", + alignment_date=_ALIGNMENT_DATE, + alignment_index=alignment_index, + ) + return accessory.to_json() + + +def _mac_at(accessoryJson: dict, index: int, key_type) -> str: + from findmy import FindMyAccessory + + accessory = FindMyAccessory.from_json(accessoryJson) + for key in accessory.keys_at(index): + if key.key_type == key_type: + return key.mac_address + raise AssertionError(f"no {key_type} key at index {index}") + + +def _ms(dt: datetime) -> int: + return int(dt.timestamp() * 1000) + + +def test_recordAccessorySeen_realigns_downward_from_a_primary_match(): + """The case the whole feature exists for: alignment drifted ahead of the truth.""" + stored = _paired_accessory(alignment_index=2880) + true_index = 2850 # inside the 12h margin, below the stored (wrong) alignment + mac = _mac_at(stored, true_index, KeyPairType.PRIMARY) + + corrected = main.recordAccessorySeen(json.dumps(stored), mac, _ms(_ALIGNMENT_DATE)) + + assert corrected is not None + parsed = json.loads(corrected) + assert parsed["alignment_index"] == true_index + assert parsed["alignment_date"] == _ALIGNMENT_DATE.isoformat() + + +def test_recordAccessorySeen_realigns_upward_from_a_primary_match(): + stored = _paired_accessory(alignment_index=2880) + true_index = 2910 # inside the 12h margin, above the stored alignment + mac = _mac_at(stored, true_index, KeyPairType.PRIMARY) + + corrected = main.recordAccessorySeen(json.dumps(stored), mac, _ms(_ALIGNMENT_DATE)) + + assert corrected is not None + assert json.loads(corrected)["alignment_index"] == true_index + + +def test_recordAccessorySeen_is_a_noop_when_already_aligned(): + """The common case, once alignment has healed: no write on every sighting thereafter.""" + stored = _paired_accessory(alignment_index=2880) + mac = _mac_at(stored, 2880, KeyPairType.PRIMARY) + + assert main.recordAccessorySeen(json.dumps(stored), mac, _ms(_ALIGNMENT_DATE)) is None + + +def test_recordAccessorySeen_with_a_hint_agrees_with_the_wide_search(): + """The hint is an optimisation, not a second rule - both paths must answer the same. + + Checking one index is three key derivations; the 48-hour window is about 1150, measured at + 1.15s on desktop and several times that under Chaquopy. Called on the sighting cadence + without the hint, the app sat at 135% CPU with two tags in range until Android killed it. + """ + stored = _paired_accessory(alignment_index=2880) + true_index = 2850 + mac = _mac_at(stored, true_index, KeyPairType.PRIMARY) + at = _ms(_ALIGNMENT_DATE) + + without_hint = main.recordAccessorySeen(json.dumps(stored), mac, at) + with_hint = main.recordAccessorySeen(json.dumps(stored), mac, at, true_index) + + assert with_hint == without_hint + assert json.loads(with_hint)["alignment_index"] == true_index + + +def test_recordAccessorySeen_falls_back_when_the_hint_is_wrong(): + """A hint that misses must cost the wide search, not the sighting. + + The candidate set may have rolled between the scan that matched and this call, so the index + it named can be one the address no longer belongs to. Trusting the hint to be exhaustive + would silently drop a correction that was there to be made. + """ + stored = _paired_accessory(alignment_index=2880) + true_index = 2850 + mac = _mac_at(stored, true_index, KeyPairType.PRIMARY) + + corrected = main.recordAccessorySeen( + json.dumps(stored), mac, _ms(_ALIGNMENT_DATE), true_index + 7) + + assert corrected is not None + assert json.loads(corrected)["alignment_index"] == true_index + + +def test_recordAccessorySeen_ignores_a_secondary_match_below_the_stored_alignment(): + """A secondary key is shared by 96 primary indices, so its reported index is a floor rather + than a fix. A floor below where alignment already stands proves nothing and moves nothing.""" + stored = _paired_accessory(alignment_index=2880) + mac = _mac_at(stored, 2850, KeyPairType.SECONDARY) + + assert main.recordAccessorySeen(json.dumps(stored), mac, _ms(_ALIGNMENT_DATE)) is None + + +def _a_secondary_reported_above(accessoryJson: dict, floor: int): + """A secondary key the app itself would report above `floor`, and the index it reports. + + Picked through `current_keys` rather than by index arithmetic, because `keys_at` yields + more than one secondary key for a given index and taking whichever comes first says + nothing about where the app would place it. The window's own answer is the input the + function under test actually receives. + """ + accessory = main.accessoryFromJson(json.dumps(accessoryJson)) + reported = accessory.current_keys(_ALIGNMENT_DATE, margin=main._MAC_CANDIDATE_MARGIN) + + for key, index in sorted(reported.items(), key=lambda pair: pair[1]): + if key.key_type == KeyPairType.SECONDARY and index > floor: + return key.mac_address, index + + raise AssertionError(f"no secondary key is reported above index {floor}") + + +def test_recordAccessorySeen_raises_the_floor_from_a_secondary_match_above_alignment(): + """The case a long-separated tag actually presents, measured on hardware. + + A tag that has been away from its owner holds a day key, so it matches on a secondary and + never on a primary - and with primary-only correction its alignment could never recover, no + matter how often somebody walked past it. Measured on a real accessory: heard at -24 dBm + lying beside the phone, its address sat 58 indices (14.5 hours) above where alignment + believed "now" was, and it was therefore absent from its own candidate set. + + Raising alignment to the secondary's own index cannot overshoot: the true index lies inside + that key's ~96-index span, and the span starts at the index reported here. + """ + stored = _paired_accessory(alignment_index=2880) + mac, reported_at = _a_secondary_reported_above(stored, 2880) + + corrected = main.recordAccessorySeen(json.dumps(stored), mac, _ms(_ALIGNMENT_DATE)) + + assert corrected is not None + parsed = json.loads(corrected) + assert parsed["alignment_index"] == reported_at + assert parsed["alignment_index"] > 2880 + + +def test_recordAccessorySeen_never_moves_the_floor_past_the_key_that_justified_it(): + """The floor may only ever undershoot the truth, which is what makes it safe to apply. + + Overshooting is the failure that produced the 114-index drift this whole path exists to + undo, and it comes from taking the *highest* index a key could belong to. This takes the + lowest, so the stored index must never exceed the index whose key was actually heard. + """ + stored = _paired_accessory(alignment_index=2880) + mac, reported_at = _a_secondary_reported_above(stored, 2880) + + corrected = main.recordAccessorySeen(json.dumps(stored), mac, _ms(_ALIGNMENT_DATE)) + + # The span this key covers starts where it was reported, so the true index is at or above + # that. Storing anything higher would be inventing certainty the key does not carry. + assert json.loads(corrected)["alignment_index"] <= reported_at + + +def test_recordAccessorySeen_refuses_a_sighting_dated_before_the_stored_alignment(): + """The backward-time guard update_alignment has, kept when bypassing it: a rolled-back + device clock must not persist a (past date, current index) pair - the index extrapolated + from that past date would overshoot once the clock corrects.""" + stored = _paired_accessory(alignment_index=2880) + true_index = 2850 + mac = _mac_at(stored, true_index, KeyPairType.PRIMARY) + + an_hour_before_alignment = _ALIGNMENT_DATE - timedelta(hours=1) + assert main.recordAccessorySeen( + json.dumps(stored), mac, _ms(an_hour_before_alignment)) is None + + +def test_recordAccessorySeen_returns_none_for_an_unmatched_address(): + stored = _paired_accessory(alignment_index=2880) + + assert main.recordAccessorySeen( + json.dumps(stored), "00:00:00:00:00:00", _ms(_ALIGNMENT_DATE)) is None + + +def test_recordAccessorySeen_ignores_a_self_generated_tag(): + """A fixed key set never rotates - update_alignment is a no-op for it too - so there is no + drift here for this to fix.""" + macs = main.currentMacAddresses(json.dumps(_CUSTOM_ACCESSORY)) + + assert main.recordAccessorySeen( + json.dumps(_CUSTOM_ACCESSORY), next(iter(macs)), _ms(_ALIGNMENT_DATE)) is None + + +def test_recordAccessorySeen_returns_none_on_garbage(): + assert main.recordAccessorySeen("not an accessory at all", "00:00:00:00:00:00", 0) is None + + def test_convertPlistToJson_returns_none_on_garbage(): """Failure must be None, not an exception - the caller retries later.""" assert main.convertPlistToJson("not a plist at all") is None @@ -1212,3 +1570,180 @@ def test_afailureToCloseIsReportedRatherThanRaised(): def test_closingNothingIsHarmless(): assert main.closeAccount(None) is False + +def test_candidate_window_agrees_with_what_current_mac_addresses_derives(): + """The cheap answer and the expensive one must describe the same slice. + + If they drift apart, a caller keeping what it derived would keep the wrong part of the + range and go on missing the tag while believing it had covered it. + """ + accessory = json.dumps(_freshly_aligned_accessory()) + + window = main.candidateWindow(accessory) + macs = main.currentMacAddresses(accessory) + + assert window is not None + assert set(macs.values()) <= set(range(window["lo"], window["hi"] + 1)) + + +def test_candidate_window_is_bounded_for_a_stale_alignment(): + """A window too wide to derive whole is reported as the bounded slice, not the true width.""" + accessory = json.dumps(_unaligned_accessory( + datetime.now(timezone.utc) - timedelta(days=400))) + + window = main.candidateWindow(accessory) + + assert window is not None + assert window["hi"] - window["lo"] <= main._MAC_CANDIDATE_MAX_INDICES + + +def test_addresses_between_covers_exactly_the_requested_range(): + accessory = json.dumps(_freshly_aligned_accessory()) + + derived = main.addressesBetween(accessory, 19100, 19150) + + assert derived is not None + assert derived + # -1 for the secondary keys, whose index would be an artefact of where the range began. + assert set(derived.values()) <= set(range(19100, 19151)) | {main._INDEX_UNKNOWN} + assert any(index != main._INDEX_UNKNOWN for index in derived.values()) + + +def test_addresses_between_is_stable_across_calls(): + """The mapping is a pure function of the keys and the index, which is what makes it + safe to store: a pair derived today has to still be true when it is read back.""" + accessory = json.dumps(_freshly_aligned_accessory()) + + first = main.addressesBetween(accessory, 19100, 19120) + second = main.addressesBetween(accessory, 19100, 19120) + + assert first == second + + +def test_addresses_between_pieces_join_up_into_the_whole_set(): + """Widening a search a piece at a time must reach the same addresses as asking once. + + This is the property the stored copy rests on. Without it, extending the range would + leave gaps that nothing would ever go back for. + """ + accessory = json.dumps(_freshly_aligned_accessory()) + + whole = main.addressesBetween(accessory, 19100, 19160) + lower = main.addressesBetween(accessory, 19100, 19130) + upper = main.addressesBetween(accessory, 19131, 19160) + + joined = dict(lower) + joined.update(upper) + + assert set(joined) == set(whole) + + +def test_a_secondary_key_reports_no_index_rather_than_a_moving_one(): + """The address set is pure; the index attached to it is not, for secondary keys. + + A secondary key covers 96 primary indices and `keys_between` de-duplicates, so it would + otherwise be reported at the first index the call's own range reaches - 19100 when asked + for 19100..19160 and 19131 for the same address when asked for 19131..19160. That is where + the search started, not a fact about the tag, so it is reported as unknown instead. Pinned + down because a caller that stored such a pair would later read it as exact. + """ + accessory = json.dumps(_freshly_aligned_accessory()) + + whole = main.addressesBetween(accessory, 19100, 19160) + upper = main.addressesBetween(accessory, 19131, 19160) + + unknown = {mac for mac, index in whole.items() if index == main._INDEX_UNKNOWN} + + assert unknown, "expected at least one secondary key in this range" + + # Every index that is reported at all agrees between the two calls, which is what makes it + # safe to keep. The ones that would have disagreed are exactly the ones reported as unknown. + for mac in set(whole) & set(upper): + if whole[mac] != main._INDEX_UNKNOWN and upper[mac] != main._INDEX_UNKNOWN: + assert whole[mac] == upper[mac] + + +def test_a_primary_key_index_survives_the_range_being_split(): + """The property the stored hint rests on: a primary key sits at one index and stays there.""" + accessory = json.dumps(_freshly_aligned_accessory()) + + whole = main.addressesBetween(accessory, 19100, 19160) + lower = main.addressesBetween(accessory, 19100, 19130) + + known = {mac: index for mac, index in lower.items() if index != main._INDEX_UNKNOWN} + + assert known + for mac, index in known.items(): + assert whole[mac] == index + + +def test_addresses_between_refuses_nothing_for_an_empty_range(): + accessory = json.dumps(_freshly_aligned_accessory()) + + assert main.addressesBetween(accessory, 500, 499) == {} + + +def test_addresses_between_returns_none_for_an_unreadable_accessory(): + assert main.addressesBetween("not json at all", 0, 10) is None + + +def test_candidate_window_returns_none_for_an_unreadable_accessory(): + assert main.candidateWindow("not json at all") is None + +def _drift_line(capsys, before_index, before_date, after_index, after_date): + main._reportDrift(before_index, before_date, after_index, after_date) + printed = capsys.readouterr().out + return [line for line in printed.splitlines() if "Alignment drift" in line] + + +def test_drift_is_not_reported_when_the_alignment_did_not_move(capsys): + """A fetch that found nothing to align to is not a drift of zero. + + Reporting it as one would fill the series with readings that say the extrapolation was + confirmed, when in fact nothing checked it. + """ + when = datetime.now(timezone.utc).isoformat() + + assert _drift_line(capsys, 19200, when, 19200, when) == [] + + +def test_drift_is_the_gap_between_extrapolation_and_where_the_tag_was(capsys): + """Six hours on, a tag that rolled on schedule is at 24 indices; one at 20 has drifted 4.""" + before = datetime(2026, 1, 1, tzinfo=timezone.utc) + after = before + timedelta(hours=6) + + lines = _drift_line(capsys, 19200, before.isoformat(), 19220, after.isoformat()) + + assert len(lines) == 1 + assert "drift 4 index/indices" in lines[0] + + +def test_a_tag_exactly_on_schedule_reports_no_drift(capsys): + before = datetime(2026, 1, 1, tzinfo=timezone.utc) + after = before + timedelta(hours=6) + + lines = _drift_line(capsys, 19200, before.isoformat(), 19224, after.isoformat()) + + assert len(lines) == 1 + assert "drift 0 index/indices" in lines[0] + + +def test_drift_is_signed_so_an_extrapolation_behind_the_tag_is_visible(capsys): + """Negative cannot happen if the extrapolation is a true upper bound, so it is worth + seeing rather than clamping away: one would mean that assumption is wrong.""" + before = datetime(2026, 1, 1, tzinfo=timezone.utc) + after = before + timedelta(hours=6) + + lines = _drift_line(capsys, 19200, before.isoformat(), 19230, after.isoformat()) + + assert len(lines) == 1 + assert "drift -6 index/indices" in lines[0] + + +def test_drift_is_silent_for_an_accessory_with_no_alignment_at_all(capsys): + assert _drift_line(capsys, None, None, 19200, "2026-01-01T00:00:00+00:00") == [] + + +def test_drift_survives_an_unparseable_date(capsys): + assert _drift_line(capsys, 19200, "not a date", 19220, "also not a date") == [] +