diff --git a/AGENTS.md b/AGENTS.md index 519367d9..b0a617f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -318,6 +318,15 @@ Eventually.perform("the sign in button", () -> apple.timesCalled("login") > 0, Two more rules that came out of the same failures: +- **`perform`'s predicate runs *before* the first attempt, so it must be cheap when the answer + is "not yet".** It is asked once up front, then twice per retry. A predicate phrased as "is + the dialog up?" runs `inRoot(isDialog())` against a screen with no dialog, and Espresso's + root picker retries internally for seconds before admitting there isn't one — so the cheap + case is the slow one, fifty times over. Seven tests in `UnlinkTheAccountSettingTest` took + **6m 33s**; asking a repository instead took **19s**. Prefer a fake's call count or a stored + value. And do not reach for `perform` at all unless the action might tear the screen down — + a click that opens a dialog cannot, so `Eventually.check` then a plain `perform(click())` is + both correct and instant. - **One `ViewAction` per `perform` when the action might finish the flow.** `perform(replaceText(code), closeSoftKeyboard())` fails on the *second* action, because the first one completed the sign-in and there is no activity left to close a keyboard on. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76fa56d7..bd9dd5e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,7 +119,7 @@ them back means redoing the macOS export. ## Testing -Tests live in five places, because the code runs in three environments: the JVM, an Android +Tests live in a lot of places, because the code runs in three environments: the JVM, an Android runtime, and CPython (both inside the app via Chaquopy, and on the desktop for the export wizard). @@ -133,6 +133,57 @@ wizard). | Desktop exporter tests | `python/test/` | pytest | no | | Shared export package | `python/opentagviewer_export/tests/` | pytest | no | | Tooling tests | `scripts/test/` | pytest | no | +| Test doubles for the bridge | `app/src/debug/python/` | installed from an instrumented test | provisioned for you | + +### Faking Apple, on the Python side of the bridge + +**Everything this app does against Apple happens behind Python, so a fake on the Java side of +the bridge skips the bridge.** That is not a hypothetical: two bugs shipped through exactly that +gap while the whole suite stayed green. + +- `PythonICloudService.openFor` checked its result with `made.toJava(Object.class)`, which + throws for any Python object. The entire iCloud flow was dead on every device, and the screen + blamed a missing account — a cause it had invented. +- `getLastReports` never emitted `wideSearch` or `exhaustedWideSearch`. Java reads both, a + missing key reads as `false`, and the silent-tag backoff quietly did nothing at all. + +Both were found by using the app. Every test of those paths replaced the Java service with a +Java fake, which is right for testing screens and means the bridge code itself — the JSON it +builds, the objects it converts, the reason strings it maps — had never run. + +So there are two doubles, and they sit **below** the code under test rather than in front of it: + +| Module | Replaces | So a test can | +| --- | --- | --- | +| `icloud_test_double` | the two functions in `exporter.icloud` that talk to Apple | drive sign-in, unlock, join, fetch, rename and close for real | +| `apple_test_double` | `main.getAccount` and `main.accessoryFromJson` | have a stored session restore, so screens that wait on one will draw | + +They live in the **debug source set**. Chaquopy compiles `src//python` alongside +`src/main/python`, so they are in the debug APK the instrumented tests run against and in no +release build. Nothing in `main` imports them; a test installs them at runtime: + +```java +final PyObject double_ = Python.getInstance().getModule("apple_test_double"); +double_.callAttr("install"); // or installWithNothingToReport() +// ... drive the app ... +double_.callAttr("uninstall"); // in @After, always +``` + +Both are idempotent on install and safe to uninstall without a matching install, but **an +uninstall that never runs leaves the fake in place for every test after it** — so it belongs in +`@After`, not at the end of the test body. + +Two things worth knowing before reaching for these: + +- **Restoring a session needs no network.** `getAccount` is `AppleAccount.from_json` and nothing + else; the sockets only appear at fetch time. That is why `apple_test_double` is small. +- **Neither of these tests Apple.** They prove this app's code is correct about a protocol it + cannot check, so they say nothing about whether Apple still accepts what is being sent. + Nothing in this repository has run against a real account in CI, and nothing can — which is + why rule 2 in [AGENTS.md](./AGENTS.md) asks you to say what you actually verified. + +`TheWholeICloudFlowAcrossTheBridgeTest` and `TheMapDrawsWhatIsStoredTest` are the worked +examples. ### Run everything @@ -159,6 +210,34 @@ afterwards it is reused. If your Python is not discovered automatically: > build skips those deliberately. If any Python-invoking tooling hangs mysteriously on > Windows, that alias is a good first suspect. +### Building a smaller APK for a local install + +The debug APK is about **105 MB**, and 65 MB of that is native libraries: Chaquopy's CPython, +`cryptography`'s OpenSSL and Apple's ADI libraries, built for both `arm64-v8a` and `x86_64`. +Whatever you install to only uses one of them. + +```bash +./gradlew :app:assembleDebug -PotvAbi=x86_64 # an emulator +./gradlew :app:assembleDebug -PotvAbi=arm64-v8a # a phone +``` + +That takes it to **68.6 MB**, and the saving is roughly double that in practice — an upgrade +needs room for the new APK while the old one is still installed. + +Worth knowing when you hit `INSTALL_FAILED_INSUFFICIENT_STORAGE`, whose message says nothing +about ABIs. The other half of that fix is the emulator itself: a Pixel AVD defaults to a 6 GB +data partition, and Device Manager → Edit → Advanced → Internal Storage raises it. Changing it +wipes the device, so export anything you care about first — an account-linked install can be +re-read, but zip-imported tags and any location history older than about seven days cannot. + +**It is for local debug installs only.** A release must carry both ABIs, so `assembleRelease` +refuses to run while `otvAbi` is set rather than quietly ignoring it. That matters because the +property is also read from `gradle.properties`, including `~/.gradle/gradle.properties` — so +setting it there to save typing would otherwise produce a release that installs on no phone +anybody owns, with a green build log. An unrecognised ABI fails the build too, rather than +producing an APK with no native libraries that installs fine and dies at the first Chaquopy +call. + ### Android instrumented tests Gradle provisions the emulator, runs the tests and tears it down — nothing needs to be diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 80c5b40d..e15480e4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,6 +24,50 @@ secrets { defaultPropertiesFileName = "local.defaults.properties" } +/** The ABIs every build carries unless asked otherwise. An emulator is the first, a phone the second. */ +val supportedAbis = listOf("arm64-v8a", "x86_64") + +/** + * `-PotvAbi=x86_64` to build one ABI instead of both. Null when nobody asked. + * + * Validated here rather than passed through: a typo would otherwise produce an APK with no + * native libraries at all, which installs perfectly happily and then dies at the first Chaquopy + * call - a far worse afternoon than a failed build. + */ +val requestedAbis: List? = providers.gradleProperty("otvAbi").orNull + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.onEach { abi -> + require(abi in supportedAbis) { "-PotvAbi=$abi is not one of $supportedAbis" } + } + +// **A release must never be built with otvAbi set.** +// +// `providers.gradleProperty` reads gradle.properties as well as -P, including the one in +// ~/.gradle. So somebody who tires of typing -PotvAbi=x86_64 and puts it there gets what they +// wanted for every local run, and also a release APK that installs on no phone anybody owns - +// with nothing to see in the build log, because it succeeded. +// +// Refused rather than silently ignored, so the flag never means two different things depending +// on which task is run. +// +// **Checked against the task graph, not in `beforeVariants`.** That hook runs for every variant +// whatever was asked for, so the release check there failed `assembleDebug` as well - which is +// the one command this property exists to serve. The graph knows what is actually going to be +// built, which is the question being asked. +if (requestedAbis != null) { + gradle.taskGraph.whenReady { + val releaseTask = this.allTasks.firstOrNull { it.name.contains("Release") } + check(releaseTask == null) { + "otvAbi is set to ${requestedAbis.joinToString(",")}, but this build runs " + + "${releaseTask?.path} and a release must carry every ABI in $supportedAbis. " + + "It is for local debug installs only - pass it with -P rather than putting it " + + "in gradle.properties." + } + } +} + android { namespace = "dev.wander.android.opentagviewer" compileSdk = 35 @@ -37,8 +81,30 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // **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 + // to two minutes, FetchFromICloudFlowTest's ten tests took 56.4s against 9.8s without, + // and the whole suite went from 2m59s to 9m13s. Measured on this machine, both ways. + // + // A hang costs one bad run and is fixed by fixing the test; this cost three minutes of + // every run forever. If a global cap is wanted, it needs to be a JUnit Timeout rule + // installed by a custom runner, not this argument. + ndk { - abiFilters += listOf("arm64-v8a", "x86_64") + // **65 MB of a 105 MB debug APK is native libraries, and half of it is for an ABI + // the target cannot run.** Chaquopy's CPython, cryptography's OpenSSL and Apple's + // ADI libraries are all here, twice over. An emulator is x86_64 and a phone is + // arm64, so a local install always carries about 32 MB it will never load. + // + // That is only a papercut until a device runs out of room, and then it is an + // INSTALL_FAILED_INSUFFICIENT_STORAGE with nothing in it about ABIs. An upgrade + // needs space for the new APK while the old one is still installed, so the real + // cost is roughly double. + // + // `-PotvAbi=x86_64` builds just the one. Opt-in, so CI, releases and anybody who + // does not know about it get both, unchanged - a default that silently shipped one + // ABI would produce a release that installs on nothing. + abiFilters += requestedAbis ?: supportedAbis } externalNativeBuild { cmake { @@ -287,7 +353,7 @@ chaquopy { // wheel for desktop platforms and a pure-Python `py3-none-any` one as well. // There is no Android wheel, so pip falls back to the pure-Python build - which // is correct but markedly slower. The messages here are small enough not to care. - install("git+https://github.com/parawanderer/FindMy.py@23a9b8d7109b405f8362ea1e69ebe51f9ca82fca") + install("git+https://github.com/parawanderer/FindMy.py@337381dedf4662d730752e727d896f964feae1f8") install("NSKeyedUnArchiver==1.5") diff --git a/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/4.json b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/4.json new file mode 100644 index 00000000..d0b318d5 --- /dev/null +++ b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/4.json @@ -0,0 +1,423 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "f139ee3fc513b953126b435980bf45ff", + "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, `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": "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, 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 + } + ], + "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, 'f139ee3fc513b953126b435980bf45ff')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/5.json b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/5.json new file mode 100644 index 00000000..767a0439 --- /dev/null +++ b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/5.json @@ -0,0 +1,442 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "b3bf79a128c0c4e6bd3306b46874a2ff", + "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, 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 + } + ], + "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, 'b3bf79a128c0c4e6bd3306b46874a2ff')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudBackPressTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudBackPressTest.java new file mode 100644 index 00000000..9a47e850 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudBackPressTest.java @@ -0,0 +1,234 @@ +package dev.wander.android.opentagviewer; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.Espresso.pressBack; +import static androidx.test.espresso.Espresso.pressBackUnconditionally; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import androidx.lifecycle.Lifecycle; +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 dev.wander.android.opentagviewer.db.AccountBeaconsForTests; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.icloud.FakeICloudService; + +/** + * What back does on each step of reading the account. + * + *

Back is where a multi-step screen quietly goes wrong. The mistakes available are all + * silent: abandoning the whole errand from a step that had somewhere to go, returning to a step + * that no longer means anything, or leaving mid-call and stranding a keychain unlock that is + * already talking to Apple. None of them throw, and none of them show up in a screenshot. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class FetchFromICloudBackPressTest { + + private FakeICloudService icloud; + private ActivityScenario scenario; + + @After + public void putTheRealOneBack() { + if (this.scenario != null) { + this.scenario.close(); + } + AppDependencies.reset(); + AccountBeaconsForTests.forgetThemAll(); + } + + @org.junit.Before + public void forgetAnyStoredMembership() { + // Finishing this flow writes real rows into the real database. Cleared here too, + // because a test that crashed left its tags behind for whatever runs next. + AccountBeaconsForTests.forgetThemAll(); + + // **The membership is in the real encrypted datastore, and it outlives a test class.** + // FetchFromICloudMembershipTest stores one; without this, every test here resumes as a + // member and never sees the device list. Cleared before rather than only after, because + // a test that crashed leaves it behind. + new dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository( + dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore.getInstance( + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation() + .getTargetContext()), + new dev.wander.android.opentagviewer.util.android.AppCryptographyUtil()) + .forget().blockingAwait(); + } + + private void open(final FakeICloudService fake) { + this.icloud = fake; + AppDependencies.replaceICloud(() -> fake); + this.scenario = ActivityScenario.launch(FetchFromICloudActivity.class); + TestPace.afterAStep(); + } + + private boolean isShown(final int id) { + final boolean[] shown = {false}; + this.scenario.onActivity(activity -> + shown[0] = activity.findViewById(id).getVisibility() == android.view.View.VISIBLE); + return shown[0]; + } + + /** + * Whether the screen has gone. + * + *

Read from the scenario rather than by asking the activity: once it is destroyed, + * `onActivity` throws "Cannot run onActivity since Activity has been destroyed already" - + * which is the very outcome these tests want, so asking that way turns a pass into a + * confusing failure. + */ + private boolean hasLeft() { + return this.scenario.getState() == Lifecycle.State.DESTROYED; + } + + private void reachThePasscodeStep(final String serial) { + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + TestPace.afterAStep(); + Eventually.perform("a device", () -> isShown(R.id.icloud_passcode_container), + () -> onView(withText(containsString(serial))).perform(click())); + TestPace.afterAStep(); + } + + /** + * From the passcode step, back goes to the device list rather than out. + * + *

Choosing the wrong device out of two is otherwise an expensive mistake: leaving costs + * the whole errand, and the user has to find the button again. + */ + @Test + public void backFromThePasscodeStepReturnsToTheDeviceList() { + this.open(FakeICloudService.withTags()); + this.reachThePasscodeStep(FakeICloudService.AN_IPHONE.getSerial()); + + TestPace.afterAStep(); + pressBack(); + TestPace.afterAStep(); + + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_passcode_container)) + .check(matches(not(isDisplayed())))); + assertTrue("the screen should still be open", !this.hasLeft()); + } + + /** And the other device can then be picked, which is the point of going back. */ + @Test + public void theotherDeviceCanBeChosenAfterGoingBack() { + this.open(FakeICloudService.withTags()); + this.reachThePasscodeStep(FakeICloudService.AN_IPHONE.getSerial()); + + TestPace.afterAStep(); + pressBack(); + TestPace.afterAStep(); + + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + Eventually.perform("the other device", () -> isShown(R.id.icloud_passcode_container), + () -> onView(withText(containsString(FakeICloudService.A_MAC.getSerial()))) + .perform(click())); + + onView(withId(R.id.icloud_passcode_input)).perform(replaceText("123456")); + Eventually.perform("unlock", () -> this.icloud.timesCalled("unlock") > 0, + () -> onView(withId(R.id.icloud_primary_button)).perform(click())); + + assertEquals("the passcode went to the device chosen after going back", + FakeICloudService.A_MAC.getSerial(), this.icloud.unlockedWith().get(0)); + } + + /** + * With one device there is nothing to go back to, so back leaves. + * + *

Returning to a list of one button is a dead end that reads as the button not working. + */ + @Test + public void backLeavesWhenThereWasNoChoiceOfDevice() { + this.open(FakeICloudService.withTags().withOneDevice()); + this.reachThePasscodeStep(FakeICloudService.AN_IPHONE.getSerial()); + + TestPace.afterAStep(); + pressBackUnconditionally(); + + Eventually.check(() -> assertTrue("back should leave when there was one device", + this.hasLeft())); + } + + /** From the device list itself, back leaves - there is no earlier step. */ + @Test + public void backFromTheDeviceListLeaves() { + this.open(FakeICloudService.withTags()); + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + + TestPace.afterAStep(); + pressBackUnconditionally(); + + Eventually.check(() -> assertTrue(this.hasLeft())); + } + + /** From a failure screen, back leaves rather than sitting there. */ + @Test + public void backFromAFailureScreenLeaves() { + this.open(FakeICloudService.withNothingToRecoverFrom()); + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(isDisplayed()))); + + TestPace.afterAStep(); + pressBackUnconditionally(); + + Eventually.check(() -> assertTrue(this.hasLeft())); + } + + /** And from the overview at the end. */ + @Test + public void backFromTheOverviewLeaves() { + this.open(FakeICloudService.withTags()); + this.reachThePasscodeStep(FakeICloudService.AN_IPHONE.getSerial()); + + onView(withId(R.id.icloud_passcode_input)).perform(replaceText("123456")); + Eventually.perform("unlock", () -> this.icloud.timesCalled("fetch") > 0, + () -> onView(withId(R.id.icloud_primary_button)).perform(click())); + + Eventually.check(() -> onView(withId(R.id.icloud_results_container)) + .check(matches(isDisplayed()))); + + TestPace.afterAStep(); + pressBackUnconditionally(); + + Eventually.check(() -> assertTrue(this.hasLeft())); + } + + /** + * Leaving always closes the session. + * + *

Two of these calls hold sockets. An abandoned session leaks them for the life of the + * process, and "the user pressed back" is by far the most common way this screen ends. + */ + @Test + public void leavingClosesTheSession() { + this.open(FakeICloudService.withTags()); + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + + TestPace.afterAStep(); + pressBackUnconditionally(); + this.scenario.close(); + this.scenario = null; + + Eventually.check(() -> assertTrue("the session was left open", this.icloud.wasClosed())); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudErrorsTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudErrorsTest.java new file mode 100644 index 00000000..0178174e --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudErrorsTest.java @@ -0,0 +1,230 @@ +package dev.wander.android.opentagviewer; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +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 dev.wander.android.opentagviewer.db.AccountBeaconsForTests; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.icloud.FakeICloudService; +import dev.wander.android.opentagviewer.python.icloud.ICloudException; +import dev.wander.android.opentagviewer.python.icloud.ICloudFailure; + +/** + * What the user is told when reading the account does not work. + * + *

Separate from the happy path because these are the cases that decide whether somebody comes + * back tomorrow or gives up - and none of them can be produced on a real account on demand. + * + *

The distinction that carries the most weight is between the two empty answers. An + * account with nothing to recover from is final and the import path is the answer; a service that + * reported nothing usable at all is very likely a bad afternoon at Apple. Showing the first when + * it is the second tells somebody with a perfectly good account that they permanently own no + * tags, and sends them off to find a friend with a Mac. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class FetchFromICloudErrorsTest { + + private FakeICloudService icloud; + private ActivityScenario scenario; + + @After + public void putTheRealOneBack() { + if (this.scenario != null) { + this.scenario.close(); + } + AppDependencies.reset(); + AccountBeaconsForTests.forgetThemAll(); + } + + @org.junit.Before + public void forgetAnyStoredMembership() { + // Finishing this flow writes real rows into the real database. Cleared here too, + // because a test that crashed left its tags behind for whatever runs next. + AccountBeaconsForTests.forgetThemAll(); + + // **The membership is in the real encrypted datastore, and it outlives a test class.** + // FetchFromICloudMembershipTest stores one; without this, every test here resumes as a + // member and never sees the device list. Cleared before rather than only after, because + // a test that crashed leaves it behind. + new dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository( + dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore.getInstance( + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation() + .getTargetContext()), + new dev.wander.android.opentagviewer.util.android.AppCryptographyUtil()) + .forget().blockingAwait(); + } + + private void open(final FakeICloudService fake) { + this.icloud = fake; + AppDependencies.replaceICloud(() -> fake); + this.scenario = ActivityScenario.launch(FetchFromICloudActivity.class); + } + + private boolean isShown(final int id) { + final boolean[] shown = {false}; + this.scenario.onActivity(activity -> + shown[0] = activity.findViewById(id).getVisibility() == android.view.View.VISIBLE); + return shown[0]; + } + + private void unlockWithTheFirstDevice() { + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + Eventually.perform("a device", () -> isShown(R.id.icloud_passcode_container), + () -> onView(withText(containsString(FakeICloudService.AN_IPHONE.getSerial()))) + .perform(click())); + + final long before = this.icloud.timesCalled("unlock"); + onView(withId(R.id.icloud_passcode_input)).perform(replaceText("123456")); + Eventually.perform("unlock", () -> this.icloud.timesCalled("unlock") > before, + () -> onView(withId(R.id.icloud_primary_button)).perform(click())); + } + + /** No device on the account can unlock the keychain: final, and import is the answer. */ + @Test + public void nothingToRecoverFromOffersTheImportPathAndNoRetry() { + this.open(FakeICloudService.withNothingToRecoverFrom()); + + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(isDisplayed()))); + // There is one button now, so what distinguishes the two screens is what it says - and + // on this one it must offer the import path rather than a retry that can never work. + Eventually.check(() -> onView(withId(R.id.icloud_primary_button)) + .check(matches(withText(R.string.icloud_import_from_file)))); + Eventually.check(() -> onView(withId(R.id.icloud_retry_container)) + .check(matches(not(isDisplayed())))); + + TestPace.afterAStep(); + } + + /** And it explains the case that actually brought most of these people here. */ + @Test + public void ittellsThemWhySharingInFindMyIsNotEnough() { + this.open(FakeICloudService.withNothingToRecoverFrom()); + + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_shared_note)) + .check(matches(isDisplayed()))); + } + + /** A service having a bad day offers a retry, and never the "you own no tags" screen. */ + @Test + public void aserviceHavingABadDayOffersARetry() { + this.open(FakeICloudService.whereTheServiceIsUnsure()); + + Eventually.check(() -> onView(withId(R.id.icloud_retry_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(not(isDisplayed())))); + + TestPace.afterAStep(); + } + + /** The two are different screens, which is the whole point. */ + @Test + public void thetwoEmptyAnswersAreNotTheSameScreen() { + assertNotEquals(ICloudFailure.NOTHING_TO_RECOVER_FROM, ICloudFailure.SERVICE_UNSURE); + + this.open(FakeICloudService.whereTheServiceIsUnsure()); + Eventually.check(() -> assertTrue(isShown(R.id.icloud_retry_container))); + this.scenario.close(); + AppDependencies.reset(); + + this.open(FakeICloudService.withNothingToRecoverFrom()); + Eventually.check(() -> assertTrue(isShown(R.id.icloud_no_tags_container))); + } + + /** + * A failure nothing anticipated lands on "try again later", with what it said. + * + *

Deliberately the safe half: "try again" about a cause nobody established is a great deal + * better than telling somebody their account is empty when it is not. + */ + @Test + public void anunrecognisedFailureSaysTryAgainRatherThanGuessing() { + this.open(FakeICloudService.withTags().whereFetchingFails( + new ICloudException(ICloudFailure.UNKNOWN, "CloudKit said something odd"))); + + this.unlockWithTheFirstDevice(); + + Eventually.check(() -> onView(withId(R.id.icloud_retry_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_retry_body)) + .check(matches(withText(containsString("CloudKit said something odd"))))); + } + + /** An unexpected failure must never be reported as "this account owns no tags". */ + @Test + public void anunrecognisedFailureIsNeverTheEmptyAccountScreen() { + this.open(FakeICloudService.withTags().whereFetchingFails( + new ICloudException(ICloudFailure.UNKNOWN, "something odd"))); + + this.unlockWithTheFirstDevice(); + + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(not(isDisplayed())))); + } + + /** An account that unlocks and holds no tags: same advice, a step later. */ + @Test + public void anaccountWithNoTagsLandsOnTheSameAdvice() { + this.open(FakeICloudService.withNoTagsOnTheAccount()); + + this.unlockWithTheFirstDevice(); + + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_primary_button)) + .check(matches(isDisplayed()))); + + TestPace.afterAStep(); + } + + /** + * A rejected passcode stays on the passcode step, with the library's own words. + * + *

Not "incorrect passcode": FindMy.py's first advice is to try the same one again, because + * the exchange has been seen to fail intermittently and then succeed. + */ + @Test + public void arejectedPasscodeStaysPutAndDoesNotCallItWrong() { + this.open(FakeICloudService.withTags().refusingThePasscode(1)); + + this.unlockWithTheFirstDevice(); + + Eventually.check(() -> onView(withId(R.id.icloud_passcode_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_passcode_error_container)) + .check(matches(isDisplayed()))); + } + + /** A rejection is not a reason to throw the user out of the flow. */ + @Test + public void arejectedPasscodeIsNotAFailureScreen() { + this.open(FakeICloudService.withTags().refusingThePasscode(1)); + + this.unlockWithTheFirstDevice(); + + Eventually.check(() -> onView(withId(R.id.icloud_retry_container)) + .check(matches(not(isDisplayed())))); + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(not(isDisplayed())))); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java new file mode 100644 index 00000000..de04bc04 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java @@ -0,0 +1,280 @@ +package dev.wander.android.opentagviewer; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +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.List; + +import dev.wander.android.opentagviewer.db.AccountBeaconsForTests; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.icloud.FakeICloudService; +import dev.wander.android.opentagviewer.python.icloud.ICloudService; + +/** + * Reading the account, driven end to end with iCloud replaced. + * + *

Every state here needs an Apple account nobody can arrange on demand - one with no device + * to recover from, one whose keychain service is having a bad afternoon, one that refuses a + * passcode three times. A working account is in none of them, so without a fake these screens + * could only ever be reasoned about, which is how a screen ends up telling somebody with a + * perfectly good account that they permanently own no tags. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class FetchFromICloudFlowTest { + + private static final String PASSCODE = "123456"; + + private FakeICloudService icloud; + private ActivityScenario scenario; + + @After + public void putTheRealOneBack() { + if (this.scenario != null) { + this.scenario.close(); + } + AppDependencies.reset(); + AccountBeaconsForTests.forgetThemAll(); + } + + @org.junit.Before + public void forgetAnyStoredMembership() { + // Finishing this flow writes real rows into the real database. Cleared here too, + // because a test that crashed left its tags behind for whatever runs next. + AccountBeaconsForTests.forgetThemAll(); + + // **The membership is in the real encrypted datastore, and it outlives a test class.** + // FetchFromICloudMembershipTest stores one; without this, every test here resumes as a + // member and never sees the device list. Cleared before rather than only after, because + // a test that crashed leaves it behind. + new dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository( + dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore.getInstance( + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation() + .getTargetContext()), + new dev.wander.android.opentagviewer.util.android.AppCryptographyUtil()) + .forget().blockingAwait(); + } + + private void open(final FakeICloudService fake) { + this.icloud = fake; + AppDependencies.replaceICloud(() -> fake); + this.scenario = ActivityScenario.launch(FetchFromICloudActivity.class); + TestPace.afterAStep(); + } + + private void chooseTheFirstDevice() { + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + TestPace.afterAStep(); + + Eventually.perform("the device button", + () -> isShown(R.id.icloud_passcode_container), + () -> onView(withText(containsString(FakeICloudService.AN_IPHONE.getSerial()))) + .perform(click())); + TestPace.afterAStep(); + } + + private void typeThePasscode() { + final long before = this.icloud.timesCalled("unlock"); + + onView(withId(R.id.icloud_passcode_input)).perform(replaceText(PASSCODE)); + TestPace.afterAStep(); + + Eventually.perform("unlock", () -> this.icloud.timesCalled("unlock") > before, + () -> onView(withId(R.id.icloud_primary_button)).perform(click())); + TestPace.afterAStep(); + } + + private boolean isShown(final int id) { + final boolean[] shown = {false}; + this.scenario.onActivity(activity -> + shown[0] = activity.findViewById(id).getVisibility() == android.view.View.VISIBLE); + return shown[0]; + } + + /** The whole errand: choose a device, unlock, see what is on the account. */ + @Test + public void thewholeFlowReachesTheTagsOnTheAccount() { + this.open(FakeICloudService.withTags().alsoSkipping("My MacBook", "My iPhone")); + + this.chooseTheFirstDevice(); + this.typeThePasscode(); + + Eventually.check(() -> onView(withId(R.id.icloud_results_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_results_found)) + .check(matches(withText(containsString("2"))))); + Eventually.check(() -> onView(withText(containsString("Bike"))) + .check(matches(isDisplayed()))); + TestPace.afterAStep(); + } + + /** The passcode goes to the device that was actually chosen, not the first in the list. */ + @Test + public void thepasscodeGoesToTheChosenDevice() { + this.open(FakeICloudService.withTags()); + + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + Eventually.perform("the second device", + () -> isShown(R.id.icloud_passcode_container), + () -> onView(withText(containsString(FakeICloudService.A_MAC.getSerial()))) + .perform(click())); + + this.typeThePasscode(); + + Eventually.check(() -> assertEquals( + List.of(FakeICloudService.A_MAC.getSerial()), this.icloud.unlockedWith())); + } + + /** + * An account with nothing that can unlock its keychain. + * + *

Final, and the answer is the import path - so the screen offers that rather than a + * retry that will never work. + */ + @Test + public void anaccountWithNothingToRecoverFromIsToldSo() { + this.open(FakeICloudService.withNothingToRecoverFrom()); + + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_shared_note)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_retry_container)) + .check(matches(not(isDisplayed())))); + TestPace.afterAStep(); + } + + /** + * And a service having a bad day is emphatically not that screen. + * + *

Collapsing the two tells somebody with a perfectly good account that they permanently + * own no tags, and sends them off to find a friend with a Mac. + */ + @Test + public void aserviceHavingABadDayOffersARetryInstead() { + this.open(FakeICloudService.whereTheServiceIsUnsure()); + + Eventually.check(() -> onView(withId(R.id.icloud_retry_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(not(isDisplayed())))); + TestPace.afterAStep(); + } + + /** Retrying starts a fresh session rather than reusing the one that failed. */ + @Test + public void retryingAsksAgain() { + this.open(FakeICloudService.whereTheServiceIsUnsure()); + Eventually.check(() -> onView(withId(R.id.icloud_retry_container)) + .check(matches(isDisplayed()))); + + final long before = this.icloud.timesCalled("recoveryOptions"); + Eventually.perform("try again", + () -> this.icloud.timesCalled("recoveryOptions") > before, + () -> onView(withId(R.id.icloud_primary_button)).perform(click())); + } + + /** An account with a Mac on it and no tags lands on the same advice, a step later. */ + @Test + public void anaccountWithNoTagsOnItSaysSoAfterFetching() { + this.open(FakeICloudService.withNoTagsOnTheAccount()); + + this.chooseTheFirstDevice(); + this.typeThePasscode(); + + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(isDisplayed()))); + } + + /** + * A rejected passcode can be tried again, and the wording does not call it wrong. + * + *

FindMy.py's own first advice is to try the same one again, because the exchange has + * been seen to fail intermittently and then succeed. + */ + @Test + public void arejectedPasscodeCanBeTriedAgain() { + this.open(FakeICloudService.withTags().refusingThePasscode(1)); + + this.chooseTheFirstDevice(); + this.typeThePasscode(); + + Eventually.check(() -> onView(withId(R.id.icloud_passcode_error_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_attempts_text)) + .check(matches(isDisplayed()))); + TestPace.afterAStep(); + + this.typeThePasscode(); + + Eventually.check(() -> onView(withId(R.id.icloud_results_container)) + .check(matches(isDisplayed()))); + } + + /** + * Attempts run out rather than going on for ever. + * + *

Apple's escrow services generally cap attempts and what this one allows is not + * established, which is a good reason not to find out on somebody's real account. + */ + @Test + public void theattemptsAreCapped() { + this.open(FakeICloudService.withTags() + .refusingThePasscode(ICloudService.MAX_UNLOCK_ATTEMPTS + 5)); + + this.chooseTheFirstDevice(); + for (int i = 0; i < ICloudService.MAX_UNLOCK_ATTEMPTS; i++) { + this.typeThePasscode(); + } + + Eventually.check(() -> assertTrue( + "more attempts were spent than the cap allows", + this.icloud.timesCalled("unlock") <= ICloudService.MAX_UNLOCK_ATTEMPTS)); + Eventually.check(() -> onView(withId(R.id.icloud_passcode_container)) + .check(matches(not(isDisplayed())))); + } + + /** An empty passcode must not spend one of them. */ + @Test + public void anemptyPasscodeSpendsNothing() { + this.open(FakeICloudService.withTags()); + this.chooseTheFirstDevice(); + + onView(withId(R.id.icloud_primary_button)).perform(click()); + + assertEquals("an empty box must not cost an attempt", 0, this.icloud.timesCalled("unlock")); + } + + /** The session is closed when the screen goes, or its sockets leak for the process's life. */ + @Test + public void thesessionIsClosedOnTheWayOut() { + this.open(FakeICloudService.withTags()); + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + + this.scenario.close(); + this.scenario = null; + + Eventually.check(() -> assertTrue("the iCloud session was never closed", + this.icloud.wasClosed())); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudMembershipTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudMembershipTest.java new file mode 100644 index 00000000..f325a27e --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudMembershipTest.java @@ -0,0 +1,213 @@ +package dev.wander.android.opentagviewer; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +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.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.db.AccountBeaconsForTests; +import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; +import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.icloud.EscrowPasscode; +import dev.wander.android.opentagviewer.python.icloud.FakeICloudService; +import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; + +/** + * Asking for the device passcode once, and never again. + * + *

This is the property the whole join exists for. Without it, refreshing the tag list + * means finding an Apple device and typing its screen-lock passcode every single time, which is + * the difference between a feature somebody uses and one they try once. + * + *

Joining is also what stops the app going quietly stale: a non-member reads with view keys it + * holds a share of, and when those roll - expected whenever the circle's membership changes - + * only a current member is given shares of the new ones. A non-member keeps its old keys, keeps + * looking fine, and decrypts nothing new. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class FetchFromICloudMembershipTest { + + private static final String PASSCODE = "123456"; + + private FakeICloudService icloud; + private ActivityScenario scenario; + private KeychainMembershipRepository memberships; + + @Before + public void forgetAnyMembership() { + this.memberships = new KeychainMembershipRepository( + UserAuthDataStore.getInstance(getInstrumentation().getTargetContext()), + new AppCryptographyUtil()); + this.memberships.forget().blockingAwait(); + // Finishing this flow writes real rows into the real database. Cleared here too, + // because a test that crashed left its tags behind for whatever runs next. + AccountBeaconsForTests.forgetThemAll(); + } + + @After + public void putTheRealOneBack() { + if (this.scenario != null) { + this.scenario.close(); + } + AppDependencies.reset(); + this.memberships.forget().blockingAwait(); + AccountBeaconsForTests.forgetThemAll(); + } + + private void open(final FakeICloudService fake) { + this.icloud = fake; + AppDependencies.replaceICloud(() -> fake); + this.scenario = ActivityScenario.launch(FetchFromICloudActivity.class); + } + + private boolean isShown(final int id) { + final boolean[] shown = {false}; + this.scenario.onActivity(activity -> + shown[0] = activity.findViewById(id).getVisibility() == android.view.View.VISIBLE); + return shown[0]; + } + + private void unlockWithTheFirstDevice() { + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + Eventually.perform("a device", () -> isShown(R.id.icloud_passcode_container), + () -> onView(withText(containsString(FakeICloudService.AN_IPHONE.getSerial()))) + .perform(click())); + + onView(withId(R.id.icloud_passcode_input)).perform(replaceText(PASSCODE)); + Eventually.perform("unlock", () -> this.icloud.timesCalled("unlock") > 0, + () -> onView(withId(R.id.icloud_primary_button)).perform(click())); + } + + /** The first run asks, joins, and stores what came back. */ + @Test + public void thefirstRunJoinsAndStoresTheMembership() { + this.open(FakeICloudService.withTags()); + + this.unlockWithTheFirstDevice(); + + Eventually.check(() -> onView(withId(R.id.icloud_results_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> assertEquals(1, this.icloud.timesCalled("join"))); + Eventually.check(() -> assertTrue("the membership was not stored", + this.memberships.get().blockingFirst().isPresent())); + } + + /** + * The passcode it enrols its own record under is a real generated secret. + * + *

Not the user's, not a constant, and not empty - enrolment refuses an empty one on the + * grounds that a record enrolled under it "could be recovered by anyone". + */ + @Test + public void itenrolsUnderAGeneratedSecretRatherThanTheUsersPasscode() { + this.open(FakeICloudService.withTags()); + + this.unlockWithTheFirstDevice(); + Eventually.check(() -> assertNotNull(this.icloud.joinedWithPasscode())); + + final String used = this.icloud.joinedWithPasscode(); + + assertTrue("the escrow passcode must be a generated secret", + EscrowPasscode.isWellFormed(used)); + assertTrue("the user's passcode must never become the record's", + !PASSCODE.equals(used)); + } + + /** + * The point of all of it. + * + *

A later run resumes as the member and never reaches the device list, so nobody is asked + * for a passcode a second time. + */ + @Test + public void alaterRunNeverAsksForAPasscodeAgain() { + this.open(FakeICloudService.withTags()); + this.unlockWithTheFirstDevice(); + Eventually.check(() -> assertTrue(this.memberships.get().blockingFirst().isPresent())); + this.scenario.close(); + AppDependencies.reset(); + + this.open(FakeICloudService.withTags()); + + Eventually.check(() -> onView(withId(R.id.icloud_results_container)) + .check(matches(isDisplayed()))); + assertEquals("a second run must not unlock", 0, this.icloud.timesCalled("unlock")); + assertEquals("a second run must not join again", 0, this.icloud.timesCalled("join")); + assertEquals("it should have read as the member it already is", + 1, this.icloud.timesCalled("resume")); + } + + /** And the device list is never shown on that later run - there is nothing to choose. */ + @Test + public void alaterRunDoesNotShowTheDeviceList() { + this.open(FakeICloudService.withTags()); + this.unlockWithTheFirstDevice(); + Eventually.check(() -> assertTrue(this.memberships.get().blockingFirst().isPresent())); + this.scenario.close(); + AppDependencies.reset(); + + this.open(FakeICloudService.withTags()); + + Eventually.check(() -> onView(withId(R.id.icloud_results_container)) + .check(matches(isDisplayed()))); + assertTrue("the device list should never have appeared", + !isShown(R.id.icloud_device_container)); + } + + /** + * A membership the account no longer honours falls back to asking, rather than failing. + * + *

Removing this app's peer is how somebody revokes it, so this is a state a real user + * creates deliberately - and the right answer is the first-run flow, not an error screen. + */ + @Test + public void amembershipTheAccountNoLongerHonoursAsksAgain() { + this.open(FakeICloudService.withTags()); + this.unlockWithTheFirstDevice(); + Eventually.check(() -> assertTrue(this.memberships.get().blockingFirst().isPresent())); + this.scenario.close(); + AppDependencies.reset(); + + this.open(FakeICloudService.withTags().whereTheMembershipNoLongerWorks()); + + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + } + + /** And it forgets the dead membership, or every later run retries keys that cannot work. */ + @Test + public void adeadMembershipIsForgotten() { + this.open(FakeICloudService.withTags()); + this.unlockWithTheFirstDevice(); + Eventually.check(() -> assertTrue(this.memberships.get().blockingFirst().isPresent())); + this.scenario.close(); + AppDependencies.reset(); + + this.open(FakeICloudService.withTags().whereTheMembershipNoLongerWorks()); + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + + Eventually.check(() -> assertTrue("the unusable membership was kept", + this.memberships.get().blockingFirst().isEmpty())); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudScrollTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudScrollTest.java new file mode 100644 index 00000000..27d5b013 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudScrollTest.java @@ -0,0 +1,156 @@ +package dev.wander.android.opentagviewer; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.swipeUp; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.view.View; +import android.widget.ScrollView; + +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.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.db.AccountBeaconsForTests; +import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; +import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.icloud.FakeICloudService; +import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; + +/** + * A long list of devices, with the button pinned under it. + * + *

Pinning the button is what makes this worth testing. While it sat at the bottom of + * the content, a long list simply pushed it further down and scrolling reached it. Now it does + * not move - so if the list above it did not scroll, the devices past the fold would be + * unreachable, and the screen would be broken for precisely the people most likely to use it: + * somebody with years of Apple hardware has an escrow record for every piece of it, and those + * records outlive the devices that made them. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class FetchFromICloudScrollTest { + + private static final int MANY = 25; + + private ActivityScenario scenario; + private KeychainMembershipRepository memberships; + + @Before + public void forgetAnyStoredMembership() { + this.memberships = new KeychainMembershipRepository( + UserAuthDataStore.getInstance(getInstrumentation().getTargetContext()), + new AppCryptographyUtil()); + this.memberships.forget().blockingAwait(); + // Finishing this flow writes real rows into the real database. Cleared here too, + // because a test that crashed left its tags behind for whatever runs next. + AccountBeaconsForTests.forgetThemAll(); + } + + @After + public void putTheRealOneBack() { + if (this.scenario != null) { + this.scenario.close(); + } + AppDependencies.reset(); + this.memberships.forget().blockingAwait(); + AccountBeaconsForTests.forgetThemAll(); + } + + private void openWithManyDevices() { + AppDependencies.replaceICloud(() -> FakeICloudService.withManyDevices(MANY)); + this.scenario = ActivityScenario.launch(FetchFromICloudActivity.class); + + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + } + + private T fromActivity(final java.util.function.Function read, + final T fallback) { + final Object[] value = {fallback}; + this.scenario.onActivity(activity -> value[0] = read.apply(activity)); + + @SuppressWarnings("unchecked") + final T typed = (T) value[0]; + return typed; + } + + private boolean canScrollDown() { + return this.fromActivity( + a -> a.findViewById(R.id.icloud_scroll).canScrollVertically(1), false); + } + + private int scrollY() { + return this.fromActivity( + a -> ((ScrollView) a.findViewById(R.id.icloud_scroll)).getScrollY(), 0); + } + + /** With this many devices there is genuinely more list than screen. */ + @Test + public void alongListOverflowsTheScreen() { + this.openWithManyDevices(); + + Eventually.check(() -> assertTrue( + "the list fits, so this test proves nothing about scrolling", canScrollDown())); + } + + /** And it can be scrolled all the way, so the last device is reachable. */ + @Test + public void thelistScrollsToTheEnd() { + this.openWithManyDevices(); + + int previous = -1; + for (int attempt = 0; attempt < 40 && scrollY() != previous; attempt++) { + previous = scrollY(); + onView(withId(R.id.icloud_scroll)).perform(swipeUp()); + } + + assertTrue("the list never moved", scrollY() > 0); + Eventually.check(() -> assertFalse( + "there are devices below the fold that cannot be reached", canScrollDown())); + } + + /** + * The button stays put while the list moves under it. + * + *

The whole reason it was pinned. If it scrolled away with the content, a long list would + * hide it - and if it moved at all, the screen would appear to jump between steps. + */ + @Test + public void thebuttonBarDoesNotMoveWhenTheListScrolls() { + this.openWithManyDevices(); + + final int before = this.fromActivity( + a -> a.findViewById(R.id.icloud_button_bar).getTop(), -1); + + onView(withId(R.id.icloud_scroll)).perform(swipeUp()); + onView(withId(R.id.icloud_scroll)).perform(swipeUp()); + + Eventually.check(() -> assertTrue("the list did not scroll at all", scrollY() > 0)); + + final int after = this.fromActivity( + a -> a.findViewById(R.id.icloud_button_bar).getTop(), -2); + + assertTrue("the button bar moved with the content", before == after); + } + + /** And it is on screen from the start, rather than below the fold with the last device. */ + @Test + public void thebuttonBarIsVisibleBeforeAnyScrolling() { + this.openWithManyDevices(); + + Eventually.check(() -> onView(withId(R.id.icloud_button_bar)) + .check(matches(isDisplayed()))); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/SignInThenGetTagsJourneyTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/SignInThenGetTagsJourneyTest.java new file mode 100644 index 00000000..ada0945c --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/SignInThenGetTagsJourneyTest.java @@ -0,0 +1,412 @@ +package dev.wander.android.opentagviewer; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.intent.Intents.intended; +import static androidx.test.espresso.intent.Intents.intending; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent; +import static androidx.test.espresso.matcher.RootMatchers.isPlatformPopup; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.app.Activity; +import android.app.Instrumentation.ActivityResult; +import android.content.Context; +import android.content.Intent; +import android.os.SystemClock; +import android.view.View; + +import androidx.lifecycle.Lifecycle; +import androidx.test.core.app.ActivityScenario; +import androidx.test.espresso.intent.Intents; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.anisette.FakeAnisetteSource; +import dev.wander.android.opentagviewer.db.AccountBeaconsForTests; +import java.util.List; + +import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; +import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; +import dev.wander.android.opentagviewer.db.repo.UserAuthRepository; +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.FakeAppleAuthService; +import dev.wander.android.opentagviewer.python.icloud.FakeICloudService; +import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; + +/** + * The whole journey, from a signed-out app to tags on the screen. + * + *

Everything here has a test of its own, and that is the point. Signing in is covered, + * reading the account is covered, writing the rows is covered - and each of those starts from a + * state the previous one is trusted to have produced. This one produces them: it signs in for + * real, opens the device list for real, reads the account through it, and looks at what ended up + * in the list. The bugs it can catch are the ones that live between two green tests. + * + *

Both journeys are here because they diverge on the answer that is out of the user's + * hands. An account with tags on it ends with a list of them; an account with none ends by + * offering the only thing that could still work - a bundle from somebody who does own some. The + * second is the one nobody exercises by hand, because producing it means having an Apple account + * with nothing in Find My. + * + *

The map is stubbed, and that is a real gap rather than a convenience. Signing in ends + * by starting {@code MapsActivity}, which needs Play Services that the {@code aosp-atd} image has + * not got, so the intent is answered at the door and the journey resumes at the device list. What + * is not covered here is therefore the map itself - the recorded next step for that is a fake + * {@code IMapProvider}. + * + *

Paced with {@link TestPace}, so this is the pair to run with {@code slowMotion} when + * somebody wants to watch the app work rather than read that it does. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class SignInThenGetTagsJourneyTest { + + private static final String EMAIL = "someone@example.com"; + private static final String PASSWORD = "hunter2"; + private static final String CODE = "123456"; + private static final String DEVICE_PASSCODE = "123456"; + + private FakeAppleAuthService apple; + private FakeICloudService icloud; + private KeychainMembershipRepository memberships; + private DeviceStateGuard deviceState; + private ActivityScenario scenario; + + @Before + public void signEverybodyOutAndReplaceTheWorld() { + final Context context = getInstrumentation().getTargetContext(); + + this.deviceState = DeviceStateGuard.capture(context); + signEverybodyOut(); + + this.memberships = new KeychainMembershipRepository( + UserAuthDataStore.getInstance(context), new AppCryptographyUtil()); + this.memberships.forget().blockingAwait(); + AccountBeaconsForTests.forgetThemAll(); + + this.apple = FakeAppleAuthService.wantsTwoFactor(); + AppDependencies.replaceAuthService(this.apple); + AppDependencies.replaceAnisette(settings -> FakeAnisetteSource.ready()); + + Intents.init(); + intending(hasComponent(MapsActivity.class.getName())) + .respondWith(new ActivityResult(Activity.RESULT_OK, null)); + } + + @After + public void putEverythingBack() { + if (this.scenario != null) { + this.scenario.close(); + } + Intents.release(); + AppDependencies.reset(); + + getInstrumentation().waitForIdleSync(); + AccountBeaconsForTests.forgetThemAll(); + this.memberships.forget().blockingAwait(); + signEverybodyOut(); + this.deviceState.restore(); + } + + /** + * An account with tags on it: sign in, read it, and they are in the list. + */ + @Test + public void signingInAndReadingTheAccountFillsTheDeviceList() { + this.icloud = FakeICloudService.withTags(); + AppDependencies.replaceICloud(() -> this.icloud); + + this.signInAllTheWayThrough(); + this.openTheDeviceList(); + + // Nothing has ever been imported, so this is the empty state and its own button is the + // way in - the same button a first-run user meets. + Eventually.check(() -> onView(withId(R.id.my_devices_empty_fetch_button)) + .check(matches(isDisplayed()))); + TestPace.afterAStep(); + onView(withId(R.id.my_devices_empty_fetch_button)).perform(click()); + + this.unlockWithADevicePasscode(); + + Eventually.check(() -> onView(withId(R.id.icloud_results_container)) + .check(matches(isDisplayed()))); + TestPace.afterAStep(); + onView(withId(R.id.icloud_primary_button)).perform(click()); + + // Back on the device list, which rebuilt itself when the fetch reported tags. + Eventually.check(() -> onView(withId(R.id.my_devices_list)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.my_devices_empty_state)) + .check(matches(not(isDisplayed())))); + TestPace.afterAStep(); + } + + /** And the tags it read are the account's, written as rows rather than held on a screen. */ + @Test + public void thetagsThatArriveAreTheOnesTheAccountHeld() { + this.icloud = FakeICloudService.withTags(); + AppDependencies.replaceICloud(() -> this.icloud); + + this.signInAllTheWayThrough(); + this.openTheDeviceList(); + onView(withId(R.id.my_devices_empty_fetch_button)).perform(click()); + this.unlockWithADevicePasscode(); + + Eventually.check(() -> assertTrue("nothing was written for the account", + this.icloud.timesCalled("records") > 0)); + } + + /** + * And what was written is usable, not just present. + * + *

The rows carry {@code accessory_json} - FindMy.py's serialised accessory state, which is + * what actually locates the tag afterwards - and it is produced by handing the account's + * plist to Python. That conversion failing is not fatal by design, because a missing one is + * backfilled on the first fetch, so a tag that imported and can never be located looks + * exactly like a tag that imported. + * + *

Which is how this went unnoticed: the fake used to return {@code ""}, the real + * converter threw on it, the failure was swallowed, and every "imported" tag in every test + * had no accessory state at all. + */ + @Test + public void whatarrivesCanActuallyBeLocated() { + this.icloud = FakeICloudService.withTags(); + AppDependencies.replaceICloud(() -> this.icloud); + + this.signInAllTheWayThrough(); + this.openTheDeviceList(); + onView(withId(R.id.my_devices_empty_fetch_button)).perform(click()); + this.unlockWithADevicePasscode(); + + Eventually.check(() -> onView(withId(R.id.icloud_results_container)) + .check(matches(isDisplayed()))); + + final OpenTagViewerDatabase db = OpenTagViewerDatabase.getInstance( + getInstrumentation().getTargetContext()); + + Eventually.check(() -> { + final List held = db.ownedBeaconDao().getAll(); + assertTrue("nothing was written for the account", !held.isEmpty()); + + for (final OwnedBeacon beacon : held) { + assertNotNull("beacon " + beacon.id + " imported with no accessory state, so it" + + " looks imported and can never be located", beacon.accessoryJson); + } + }); + } + + /** + * An account with nothing on it says so, and offers the only thing that could work. + * + *

A dead end here is a user who signed in, waited, and was told nothing at all. The screen + * hands them back to the file picker instead, which is how somebody whose tags belong to a + * family member gets anywhere. + */ + @Test + public void signingInWithNothingOnTheAccountOffersAFileInstead() { + this.icloud = FakeICloudService.withNothingToRecoverFrom(); + AppDependencies.replaceICloud(() -> this.icloud); + + this.signInAllTheWayThrough(); + this.openTheDeviceList(); + + Eventually.check(() -> onView(withId(R.id.my_devices_empty_fetch_button)) + .check(matches(isDisplayed()))); + TestPace.afterAStep(); + onView(withId(R.id.my_devices_empty_fetch_button)).perform(click()); + + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(isDisplayed()))); + TestPace.afterAStep(); + } + + /** + * And taking that offer hands the whole journey on to the file picker. + * + *

The list finishes itself, which looks like a bug and is not. The picker and the + * code that fetches locations for whatever comes back both live on the map, so the request is + * passed back rather than duplicated - and the map is underneath in a real run. Here there is + * nothing underneath, because the map was stubbed at the door, so the stack simply empties. + * + *

That is why this asserts the hand-off rather than looking for a screen: the first + * version of this test expected the device list to still be there and failed with + * {@code NoActivityResumedException}, which reads like a crash and is the app doing exactly + * what it should. + */ + @Test + public void takingTheFileOfferHandsTheJourneyOnRatherThanStopping() { + this.icloud = FakeICloudService.withNothingToRecoverFrom(); + AppDependencies.replaceICloud(() -> this.icloud); + + this.signInAllTheWayThrough(); + this.openTheDeviceList(); + onView(withId(R.id.my_devices_empty_fetch_button)).perform(click()); + + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(isDisplayed()))); + TestPace.afterAStep(); + onView(withId(R.id.icloud_primary_button)).perform(click()); + + // Asked of the scenario rather than of Espresso: there is no activity left to look at, + // and onView would report that as a failure rather than as the answer. + Eventually.check(() -> assertEquals( + "the device list should have handed the import request onward", + Lifecycle.State.DESTROYED, this.scenario.getState())); + TestPace.afterAStep(); + } + + /** The second run asks for no passcode at all, from a cold start of the whole journey. */ + @Test + public void asecondReadAfterSigningInNeverAsksForAPasscode() { + this.icloud = FakeICloudService.withTags(); + AppDependencies.replaceICloud(() -> this.icloud); + + this.signInAllTheWayThrough(); + this.openTheDeviceList(); + onView(withId(R.id.my_devices_empty_fetch_button)).perform(click()); + this.unlockWithADevicePasscode(); + Eventually.check(() -> onView(withId(R.id.icloud_results_container)) + .check(matches(isDisplayed()))); + onView(withId(R.id.icloud_primary_button)).perform(click()); + + // **The screen is opened directly for the second read, deliberately.** Which button + // reaches it is being redesigned - linking is offered until the account is linked, and + // re-reading is moving to something the app does on its own - and the property under + // test is not about buttons. It is that a member reads without asking for anything, and + // tying that assertion to whichever affordance exists this week is how a test starts + // failing for reasons that have nothing to do with what it protects. + this.scenario.close(); + this.icloud = FakeICloudService.withTags(); + AppDependencies.replaceICloud(() -> this.icloud); + + this.scenario = ActivityScenario.launch(new Intent( + getInstrumentation().getTargetContext(), FetchFromICloudActivity.class)); + + Eventually.check(() -> onView(withId(R.id.icloud_results_container)) + .check(matches(isDisplayed()))); + assertEquals("a second read must not ask for a passcode", + 0, this.icloud.timesCalled("unlock")); + assertEquals("it should have read as the member it already is", + 1, this.icloud.timesCalled("resume")); + TestPace.afterAStep(); + } + + // ------------------------------------------------------------------ the journey, in steps + + private void signInAllTheWayThrough() { + this.scenario = ActivityScenario.launch(AppleLoginActivity.class); + TestPace.afterAStep(); + + onView(withId(R.id.email_or_phone_input_field)).perform(replaceText(EMAIL)); + TestPace.afterAStep(); + onView(withId(R.id.password_input_field)) + .perform(replaceText(PASSWORD), closeSoftKeyboard()); + TestPace.afterAStep(); + + Eventually.perform("the sign in button", () -> this.apple.timesCalled("login") > 0, + () -> onView(withId(R.id.login_button_main)).perform(click())); + TestPace.afterAStep(); + + Eventually.check(() -> onView(withText( + getInstrumentation().getTargetContext().getString( + R.string.auth_by_sms_to_x, FakeAppleAuthService.PHONE_ONE))) + .perform(click())); + TestPace.afterAStep(); + + Eventually.check(() -> onView(withId(R.id.twofa_sent_info_text)) + .check(matches(isDisplayed()))); + // Pasted rather than typed: the boxes move focus as they fill, so per-character typing + // fails the moment the field it started on stops being focused - and pasting is what + // people do with a code they were just sent. + onView(withId(R.id.twofactorauth_textinput_1)).perform(replaceText(CODE)); + TestPace.afterAStep(); + + Eventually.check(() -> intended(hasComponent(MapsActivity.class.getName()))); + + // The sign-in screen finishes itself once the map is on its way. Closed here so the + // device list is not launched on top of a screen that is still tearing down. + this.scenario.close(); + this.scenario = null; + } + + private void openTheDeviceList() { + this.scenario = ActivityScenario.launch(MyDevicesListActivity.class); + TestPace.afterAStep(); + } + + private boolean isShown(final int id) { + final boolean[] shown = {false}; + this.scenario.onActivity(activity -> { + final View found = activity.findViewById(id); + shown[0] = found != null && found.getVisibility() == View.VISIBLE; + }); + return shown[0]; + } + + private void unlockWithADevicePasscode() { + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + TestPace.afterAStep(); + + Eventually.check(() -> onView(withText(containsString( + FakeICloudService.AN_IPHONE.getSerial()))).perform(click())); + TestPace.afterAStep(); + + Eventually.check(() -> onView(withId(R.id.icloud_passcode_input)) + .check(matches(isDisplayed()))); + onView(withId(R.id.icloud_passcode_input)).perform(replaceText(DEVICE_PASSCODE)); + TestPace.afterAStep(); + + Eventually.perform("unlock", () -> this.icloud.timesCalled("unlock") > 0, + () -> onView(withId(R.id.icloud_primary_button)).perform(click())); + TestPace.afterAStep(); + } + + /** + * Clear any stored session, and wait until it stays cleared. + * + *

The same insistence as {@code AppleLoginFlowTest}: storing a session is the last step of + * a sign-in and runs on a background scheduler, so a single look can see an empty store that + * is about to be written to. + */ + private static void signEverybodyOut() { + final UserAuthRepository auth = new UserAuthRepository( + UserAuthDataStore.getInstance(getInstrumentation().getTargetContext()), + new AppCryptographyUtil()); + + int consecutivelyEmpty = 0; + + for (int attempt = 0; attempt < 40 && consecutivelyEmpty < 3; attempt++) { + if (auth.getUserAuth().blockingFirst().isEmpty()) { + consecutivelyEmpty++; + } else { + consecutivelyEmpty = 0; + auth.clearUser().blockingAwait(); + } + SystemClock.sleep(50); + } + + if (consecutivelyEmpty < 3) { + throw new IllegalStateException("a stored session kept coming back"); + } + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/AccountBeaconsForTests.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/AccountBeaconsForTests.java new file mode 100644 index 00000000..17d3ffe5 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/AccountBeaconsForTests.java @@ -0,0 +1,43 @@ +package dev.wander.android.opentagviewer.db; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + +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.OwnedBeacon; + +/** + * Undo what a test's iCloud import wrote to the real database. + * + *

A test that finishes the iCloud flow writes real rows. The activity holds + * {@code OpenTagViewerDatabase.getInstance}, which is a plain singleton with no test seam, so + * every fake tag the flow "imports" lands in the same database the app uses - visible in My + * Devices, on the map, and for as long as that install exists. On the managed device that is one + * run; on a developer's own device it is a handful of tags they did not import and cannot + * explain, and {@code allowBackup} is false, so there is nothing to restore from. + * + *

It also breaks other tests, which is how it was found: {@code RemoveAccountTagTest} seeds + * three known tags and looks for them by name, and passed alone while failing in the full suite + * because the leftovers had pushed its rows off the bottom of the list. + * + *

Scoped to {@code from_account} rows, so a file-imported tag - somebody's real one, if this + * is ever run against a real install - is never touched. Same reasoning as + * {@code OwnedBeaconDao#retireAccountBeaconsMissingFrom}, and the same reason + * {@code clearAllTables} is not used here. + */ +public final class AccountBeaconsForTests { + + private AccountBeaconsForTests() { + } + + /** Call from {@code @Before} as well as {@code @After} - a crashed test cleans up neither. */ + public static void forgetThemAll() { + final OpenTagViewerDatabase db = + OpenTagViewerDatabase.getInstance(getInstrumentation().getTargetContext()); + + for (final String id : db.ownedBeaconDao().getAccountBeaconIds()) { + db.ownedBeaconDao().delete(OwnedBeacon.builder().id(id).build()); + db.beaconNamingRecordDao().delete(BeaconNamingRecord.builder().id(id).build()); + } + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/AFirstFetchLooksFurtherBackTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/AFirstFetchLooksFurtherBackTest.java new file mode 100644 index 00000000..21c09c6a --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/AFirstFetchLooksFurtherBackTest.java @@ -0,0 +1,132 @@ +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.Set; + +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; + +/** + * Which tags get the wider first window. + * + *

The bug, in @parawanderer's words: a tag showed "No last location known" in My + * Devices, and opening its history and paging back a few days found locations perfectly well. + * The reports were on Apple's servers the whole time; the fetch had only ever asked about the + * last twenty-four hours, which is right for a tag the app has been watching and wrong for one + * that arrived five minutes ago with no history at all. + * + *

So a beacon nothing has ever searched for is asked about across the whole week Apple + * retains. This is the query that decides which those are, and the properties that matter are + * that it starts true and stops being true - a window that never narrowed would make every + * routine refresh seven times the work forever. + */ +@RunWith(AndroidJUnit4.class) +public class AFirstFetchLooksFurtherBackTest { + + 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(); + } + + private void givenAbeacon(final String id) { + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(id) + .content(A_PLIST) + .version("0.0.2") + .fromAccount(false) + .isRemoved(false) + .build()); + } + + private Set neverScanned() { + return this.repo.neverScanned().blockingFirst(); + } + + /** A tag that has just been imported has never been searched for. */ + @Test + public void afreshlyImportedTagGetsTheWiderWindow() { + this.givenAbeacon("just-arrived"); + + assertTrue("a tag with no scan history was not offered the wider first window", + this.neverScanned().contains("just-arrived")); + } + + /** + * And it stops after the first search, whatever that search found. + * + *

The property that keeps this from becoming permanent. Keyed on having been searched, + * not on having succeeded - a tag that is genuinely silent would otherwise be asked for a + * full week every single refresh, forever, which is the opposite of what the backoff is for. + */ + @Test + public void itstopsOnceThetagHasBeenSearchedForAtAll() { + this.givenAbeacon("searched-and-found"); + this.givenAbeacon("searched-and-empty"); + + this.db.ownedBeaconDao().recordSuccessfulScan("searched-and-found", 1_000L); + this.db.ownedBeaconDao().recordFruitlessScan("searched-and-empty", 1_000L); + + final Set stillNew = this.neverScanned(); + + assertFalse("a tag that answered is still being asked for a whole week", + stillNew.contains("searched-and-found")); + assertFalse("a tag that was searched and found nothing is still being asked for a " + + "whole week, every refresh, forever", stillNew.contains("searched-and-empty")); + } + + /** + * A tag set aside as silent is not treated as new either. + * + *

It has been searched - exhaustively, which is why it was set aside. Looking again is + * something the user asks for on the tag page, and that path widens its own window. + */ + @Test + public void anignoredTagIsNotMistakenForAnewOne() { + this.givenAbeacon("given-up-on"); + this.db.ownedBeaconDao().markIgnored("given-up-on", 2_000L); + + assertFalse(this.neverScanned().contains("given-up-on")); + } + + /** A removed tag is nobody's business, wide window or otherwise. */ + @Test + public void aremovedTagIsNotOfferedAnything() { + this.givenAbeacon("gone"); + this.db.ownedBeaconDao().setRemoved("gone"); + + assertFalse(this.neverScanned().contains("gone")); + } + + /** Nothing stored, nothing offered - and no exception on the way. */ + @Test + public void anemptyDatabaseOffersNothing() { + assertEquals(Set.of(), this.neverScanned()); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/AccountBeaconRefreshTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/AccountBeaconRefreshTest.java new file mode 100644 index 00000000..73cb3d00 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/AccountBeaconRefreshTest.java @@ -0,0 +1,171 @@ +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.List; +import java.util.stream.Collectors; + +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.icloud.AccessoryRecords; + +/** + * Bringing the beacons held for an Apple account into line with what it holds. + * + *

The property being protected is not "the refresh works". It is that a refresh cannot + * reach a file-imported tag. Those rows are the only copy anyone has - the export they came from + * may be long gone, and {@code allowBackup} is false - so a refresh that deleted one would be + * unrecoverable data loss triggered by an ordinary action. + */ +@RunWith(AndroidJUnit4.class) +public class AccountBeaconRefreshTest { + + 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(); + + // The real converter needs a running Python runtime; nothing here is about conversion. + this.repo = new BeaconRepository(this.db, (plist, alignment) -> "{\"type\":\"accessory\"}"); + } + + @After + public void closeIt() { + this.db.close(); + } + + private static AccessoryRecords fromAccount(final String id) { + return new AccessoryRecords(id, A_PLIST, A_PLIST, null); + } + + private void givenAFileImportedBeacon(final String id) { + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(id) + .content(A_PLIST) + .version("0.0.2") + .fromAccount(false) + .isRemoved(false) + .build()); + } + + private List liveBeaconIds() { + return this.db.ownedBeaconDao().getAll().stream() + .map(beacon -> beacon.id) + .collect(Collectors.toList()); + } + + @Test + public void whatIsOnTheAccountIsWritten() { + this.repo.refreshAccountBeacons(List.of(fromAccount("a"), fromAccount("b"))) + .blockingFirst(); + + assertEquals(2, this.liveBeaconIds().size()); + assertTrue(this.liveBeaconIds().containsAll(List.of("a", "b"))); + } + + @Test + public void whatIsWrittenIsMarkedAsComingFromTheAccount() { + this.repo.refreshAccountBeacons(List.of(fromAccount("a"))).blockingFirst(); + + assertEquals(List.of("a"), this.db.ownedBeaconDao().getAccountBeaconIds()); + } + + /** A tag that has left the account goes from here too - these rows are a cache. */ + @Test + public void whatHasLeftTheAccountIsRetired() { + this.repo.refreshAccountBeacons(List.of(fromAccount("a"), fromAccount("b"))) + .blockingFirst(); + + this.repo.refreshAccountBeacons(List.of(fromAccount("a"))).blockingFirst(); + + assertEquals(List.of("a"), this.liveBeaconIds()); + } + + /** + * The one that matters. + * + *

A file-imported tag is untouched by a refresh, including a refresh that finds nothing. + * Without the {@code from_account} scope this test is what fails - and in production it would + * be somebody's tags, gone, with no export to redo them from. + */ + @Test + public void afileImportedBeaconIsNeverTouchedByARefresh() { + this.givenAFileImportedBeacon("from-a-zip"); + + this.repo.refreshAccountBeacons(List.of(fromAccount("a"))).blockingFirst(); + assertTrue("a refresh removed a file-imported tag", + this.liveBeaconIds().contains("from-a-zip")); + + this.repo.refreshAccountBeacons(List.of()).blockingFirst(); + assertTrue("an empty account removed a file-imported tag", + this.liveBeaconIds().contains("from-a-zip")); + } + + /** An account that now holds nothing retires its own rows - and only its own. */ + @Test + public void anemptyAccountRetiresOnlyItsOwn() { + this.givenAFileImportedBeacon("from-a-zip"); + this.repo.refreshAccountBeacons(List.of(fromAccount("a"))).blockingFirst(); + + this.repo.refreshAccountBeacons(List.of()).blockingFirst(); + + assertEquals(List.of("from-a-zip"), this.liveBeaconIds()); + assertTrue(this.db.ownedBeaconDao().getAccountBeaconIds().isEmpty()); + } + + /** + * A tag that left and came back is live again. + * + *

The row is retired rather than deleted, so re-inserting it has to clear that flag or it + * comes back invisible - present in the table, absent from every screen. + */ + @Test + public void atagThatComesBackIsLiveAgain() { + this.repo.refreshAccountBeacons(List.of(fromAccount("a"))).blockingFirst(); + this.repo.refreshAccountBeacons(List.of()).blockingFirst(); + assertFalse(this.liveBeaconIds().contains("a")); + + this.repo.refreshAccountBeacons(List.of(fromAccount("a"))).blockingFirst(); + + assertTrue("a tag that returned to the account stayed hidden", + this.liveBeaconIds().contains("a")); + } + + /** Refreshing twice with the same account does not duplicate anything. */ + @Test + public void refreshingTwiceChangesNothing() { + this.repo.refreshAccountBeacons(List.of(fromAccount("a"), fromAccount("b"))) + .blockingFirst(); + this.repo.refreshAccountBeacons(List.of(fromAccount("a"), fromAccount("b"))) + .blockingFirst(); + + assertEquals(2, this.liveBeaconIds().size()); + } + + /** An accessory nothing ever named still gets a row - the app can show one of those. */ + @Test + public void anaccessoryWithNoNamingRecordIsStillWritten() { + this.repo.refreshAccountBeacons( + List.of(new AccessoryRecords("nameless", A_PLIST, null, null))).blockingFirst(); + + assertTrue(this.liveBeaconIds().contains("nameless")); + } +} 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 new file mode 100644 index 00000000..3fe676a3 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/AccountRefreshKeepsWhatTheUserOwnsTest.java @@ -0,0 +1,245 @@ +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.assertNotNull; +import static org.junit.Assert.assertNull; + +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.ArrayList; +import java.util.List; + +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.db.repo.model.ImportData; +import dev.wander.android.opentagviewer.db.room.entity.Import; +import dev.wander.android.opentagviewer.db.room.entity.LocationReport; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.db.room.entity.UserBeaconOptions; +import dev.wander.android.opentagviewer.python.icloud.AccessoryRecords; + +/** + * What an account read is allowed to destroy, which is nothing the user put there. + * + *

This is a data-loss test, not a correctness test. The account read runs on its own, + * every six hours and on every start, against tags the user did not ask it to touch. Anything it + * silently removes is removed without anybody performing an action they could connect it to - + * they restart the app and their custom names are gone. + * + *

The mechanism is worth stating because it is invisible at the Java layer. Room's + * {@code @Insert(onConflict = REPLACE)} compiles to SQLite's {@code INSERT OR REPLACE}, and that + * is not an update: it deletes the conflicting row and inserts a new one. Room enables + * {@code PRAGMA foreign_keys}, so that delete runs the {@code ON DELETE CASCADE} on every child + * table - and {@code UserBeaconOptions} and {@code LocationReport} are both children of + * {@code OwnedBeacons}. Re-writing a row that already exists therefore erases the user's name and + * emoji for that tag, and its entire location history, while reading exactly like an upsert. + * + *

Reported by @parawanderer: an iPad, a MacBook and a duplicate iPad were given custom emoji, + * and the rows were gone after a restart. + */ +@RunWith(AndroidJUnit4.class) +public class AccountRefreshKeepsWhatTheUserOwnsTest { + + private static final String A_PLIST = ""; + private static final String THE_IPAD = "ipad-1"; + + 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(); + } + + private static AccessoryRecords fromAccount(final String id) { + // (beaconId, ownedBeaconPlist, namingRecordPlist, keyAlignmentPlist) + return new AccessoryRecords(id, A_PLIST, null, A_PLIST); + } + + /** The tag is already held, exactly as a previous account read would have left it. */ + private void givenTheTagIsAlreadyKnown() { + this.repo.refreshAccountBeacons(List.of(fromAccount(THE_IPAD))).blockingFirst(); + } + + private void givenTheUserNamedIt(final String name, final String emoji) { + this.db.userBeaconOptionsDao().insertAll(UserBeaconOptions.builder() + .beaconId(THE_IPAD) + .lastUpdate(1_000L) + .uiName(name) + .uiEmoji(emoji) + .build()); + } + + private void givenItHasSomeHistory() { + this.db.locationReportDao().insertAll(LocationReport.builder() + .hashId("a-report") + .beaconId(THE_IPAD) + .publishedAt(1_000L) + .description("Wi-Fi") + .timestamp(1_000L) + .confidence(0) + .latitude(52.370216) + .longitude(4.895168) + .horizontalAccuracy(83) + .status(144) + .lastUpdate(1_000L) + .build()); + } + + private UserBeaconOptions storedOptions() { + return this.db.userBeaconOptionsDao().getById(THE_IPAD); + } + + /** + * The reported bug. A custom name and emoji must survive the next account read. + * + *

The user set these deliberately, on tags whose real names are unhelpful - two iPads, one + * of them a stale duplicate. Losing them means doing the work again after every restart, and + * nothing on screen explains why. + */ + @Test + public void acustomNameAndEmojiSurviveAnAccountRead() { + this.givenTheTagIsAlreadyKnown(); + this.givenTheUserNamedIt("Studio iPad", "🎨"); + + this.repo.refreshAccountBeacons(List.of(fromAccount(THE_IPAD))).blockingFirst(); + + final UserBeaconOptions held = this.storedOptions(); + assertNotNull("the user's name and emoji were destroyed by an account read", held); + assertEquals("Studio iPad", held.uiName); + assertEquals("🎨", held.uiEmoji); + } + + /** + * And so must the location history. + * + *

The same cascade reaches {@code LocationReport}, which is worse: Apple keeps about seven + * days, so history older than that exists only here. An account read that wipes it is + * destroying the one copy. + */ + @Test + public void locationHistorySurvivesAnAccountRead() { + this.givenTheTagIsAlreadyKnown(); + this.givenItHasSomeHistory(); + + this.repo.refreshAccountBeacons(List.of(fromAccount(THE_IPAD))).blockingFirst(); + + assertEquals("an account read destroyed the tag's location history", + 1, this.db.locationReportDao() + .getInTimeRange(THE_IPAD, 0L, 10_000L).size()); + } + + /** + * The backoff state survives too. + * + *

Otherwise every six-hourly account read resets the silent-tag handling to zero, and a tag + * that has been given up on quietly comes back to being scanned - which is the whole cost that + * logic exists to avoid, paid four times a day. + */ + @Test + public void thesilentTagStateSurvivesAnAccountRead() { + this.givenTheTagIsAlreadyKnown(); + this.db.ownedBeaconDao().markIgnored(THE_IPAD, 5_000L); + + this.repo.refreshAccountBeacons(List.of(fromAccount(THE_IPAD))).blockingFirst(); + + final OwnedBeacon held = this.db.ownedBeaconDao().getById(THE_IPAD); + assertNotNull(held); + assertEquals("the tag was un-ignored by an account read", + Long.valueOf(5_000L), held.ignoredAt); + assertEquals("its strike count was reset by an account read", 1, held.fruitlessScans); + } + + /** + * What the account read is for still happens: the stored plists are brought up to date. + * + *

Guarding the fix from the other side. Making the write skip existing rows entirely would + * pass every test above and quietly stop the app ever picking up a re-paired tag's new keys. + */ + @Test + public void thestoredRecordsAreStillUpdated() { + this.givenTheTagIsAlreadyKnown(); + + final String newer = "v2"; + this.repo.refreshAccountBeacons( + List.of(new AccessoryRecords(THE_IPAD, newer, null, newer))) + .blockingFirst(); + + final OwnedBeacon held = this.db.ownedBeaconDao().getById(THE_IPAD); + assertNotNull(held); + assertEquals("the account read stopped updating the stored plist", newer, held.content); + assertEquals(newer, held.alignmentPlist); + } + + /** A tag the user never named has no row, and the read must not invent one. */ + @Test + public void atagWithNoCustomNameStillHasNone() { + this.givenTheTagIsAlreadyKnown(); + + this.repo.refreshAccountBeacons(List.of(fromAccount(THE_IPAD))).blockingFirst(); + + assertNull(this.storedOptions()); + } + + private ImportData animportOf(final String beaconId, final String plist) { + return new ImportData( + Import.builder() + .version("0.0.2") + .importedAt(1_000L) + .exportedAt(1_000L) + .sourceUser("someone@example.com") + .exportedVia("OpenTagViewer.wizard:test") + .build(), + new ArrayList<>(List.of(OwnedBeacon.builder() + .id(beaconId) + .content(plist) + .version("0.0.2") + .fromAccount(false) + .isRemoved(false) + .build())), + new ArrayList<>()); + } + + /** + * The same defect on the import path, which is the one people repeat deliberately. + * + *

Re-importing is not an error case: an export made after format {@code 0.0.2} carries a + * key alignment record that an older one lacks, and the advice for a tag searching its whole + * history is to export again. Doing that must not cost the user everything they have + * accumulated for the tag - {@code addNewImport} says in its own javadoc that it updates + * beacons that already exist, so it lands on exactly this cascade. + */ + @Test + public void areImportKeepsTheCustomNameAndTheHistory() throws Exception { + this.repo.addNewImport(this.animportOf(THE_IPAD, A_PLIST)).blockingFirst(); + this.givenTheUserNamedIt("Studio iPad", "🎨"); + this.givenItHasSomeHistory(); + + final String newer = "v2"; + this.repo.addNewImport(this.animportOf(THE_IPAD, newer)).blockingFirst(); + + final UserBeaconOptions held = this.storedOptions(); + assertNotNull("re-importing destroyed the user's name and emoji", held); + assertEquals("Studio iPad", held.uiName); + assertEquals("re-importing destroyed the tag's location history", + 1, this.db.locationReportDao().getInTimeRange(THE_IPAD, 0L, 10_000L).size()); + assertEquals("re-importing did not update the stored plist", + newer, this.db.ownedBeaconDao().getById(THE_IPAD).content); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java index 0f59adf9..bbd27324 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java @@ -335,7 +335,10 @@ public void storeFetchResultPersistsUpdatedAccessoryJson() { final String updated = "{\"type\":\"accessory\",\"alignment_index\":4242}"; var fetchResult = new dev.wander.android.opentagviewer.python.FetchResult( Collections.emptyMap(), - Map.of("beacon-a", updated) + Map.of("beacon-a", updated), + // Nothing was searched wide and found empty here; this test is about the + // accessory JSON being written back, not about tags that have gone quiet. + java.util.Set.of(), java.util.Set.of() ); repo.storeFetchResult(fetchResult).blockingFirst(); @@ -353,7 +356,7 @@ public void storeFetchResultIgnoresNullUpdates() { var updates = new java.util.HashMap(); updates.put("beacon-a", null); var fetchResult = new dev.wander.android.opentagviewer.python.FetchResult( - Collections.emptyMap(), updates); + Collections.emptyMap(), updates, java.util.Set.of(), java.util.Set.of()); repo.storeFetchResult(fetchResult).blockingFirst(); diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/GivingUpOnASilentTagTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/GivingUpOnASilentTagTest.java new file mode 100644 index 00000000..effe8f54 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/GivingUpOnASilentTagTest.java @@ -0,0 +1,299 @@ +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.assertNotNull; +import static org.junit.Assert.assertNull; +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.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.AccessoryRequest; +import dev.wander.android.opentagviewer.python.FetchResult; +import dev.wander.android.opentagviewer.util.rx.WideScanBackoff; + +/** + * Noticing that a tag has stopped broadcasting, and asking about it less. + * + *

Silent tags are the expensive ones. A tag with no key alignment record searches from + * its pairing date at a request per ~290 keys, so one that nobody has walked past costs hundreds + * of requests where a healthy tag costs one - and it costs them again on every refresh. Left + * alone the app spends most of its conversation with Apple on the answers least likely to come, + * which is rule 6's account-flagging risk arriving through a different door. + * + *

Three outcomes, not two, and the distinctions are the whole feature. Something found + * clears everything. Nothing found lengthens the wait a little, because a fortnight in a drawer + * is a normal tag having a normal week. Nothing found across months of history is + * different in kind, and only that one is given up on. + * + *

None of this is visible when it goes wrong. Too eager is a quiet flood of requests; too keen + * to give up is an app that stops looking for somebody's bike. + */ +@RunWith(AndroidJUnit4.class) +public class GivingUpOnASilentTagTest { + + private static final String A_TAG = "a-tag"; + 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\"}"); + + 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(); + } + + private OwnedBeacon stored() { + return this.db.ownedBeaconDao().getById(A_TAG); + } + + /** + * The four kinds of scan outcome, named rather than passed as booleans. + * + *

They were one helper taking {@code (found, wide)} until that turned out not to express + * the middle case at all: "expensive and empty" and "empty across months of history" are + * different answers with different consequences, and a single flag quietly made every + * expensive empty search a death sentence. + */ + private void aSearchThatFoundSomething() { + this.store(true, false, false); + } + + /** Aligned tag, narrow window: an empty answer means "has not moved", not "is gone". */ + private void aCheapSearchThatFoundNothing() { + this.store(false, false, false); + } + + /** Expensive, and empty - worth waiting longer before trying again. */ + private void aWideSearchThatFoundNothing() { + this.store(false, true, false); + } + + /** Expensive, empty, and covering months: the tag has stopped broadcasting. */ + private void aWideSearchAcrossMonthsThatFoundNothing() { + this.store(false, true, true); + } + + private void store(final boolean found, final boolean wide, final boolean exhausted) { + final Map> reports = Map.of( + A_TAG, found + ? List.of(BeaconLocationReport.builder() + .timestamp(1L).publishedAt(1L).description("somewhere") + .latitude(1).longitude(1).build()) + : List.of()); + + this.repo.storeFetchResult(new FetchResult( + reports, + Collections.emptyMap(), + exhausted ? Set.of(A_TAG) : Set.of(), + wide ? Set.of(A_TAG) : Set.of())).blockingFirst(); + } + + /** A search that found something clears everything held against the tag. */ + @Test + public void atagThatReportsIsAnOrdinaryTagAgain() { + this.aWideSearchThatFoundNothing(); + this.aWideSearchThatFoundNothing(); + assertEquals(2, this.stored().fruitlessScans); + + this.aSearchThatFoundSomething(); + + assertEquals("a tag that answered must go back to being asked normally", + 0, this.stored().fruitlessScans); + assertNull(this.stored().ignoredAt); + assertNotNull("the attempt should still be recorded", this.stored().lastScanAt); + } + + /** An ordinary empty wide search lengthens the wait, and nothing more. */ + @Test + public void anemptySearchBacksOffRatherThanGivingUp() { + this.aWideSearchThatFoundNothing(); + + assertEquals(1, this.stored().fruitlessScans); + assertNull("one quiet week is not evidence of anything", this.stored().ignoredAt); + } + + /** + * A cheap search finding nothing new is not held against the tag at all. + * + *

The bug @parawanderer spotted in the database: tags updating happily every day were + * carrying fruitless_scans of 1. An aligned tag costs a request or two, and an empty answer + * from one means "nothing new in the window asked for" - which is simply what a tag that + * reported an hour ago and has not moved looks like. Counting it made healthy tags accrue + * strikes and drift towards being asked less often, for doing nothing wrong. + */ + @Test + public void anemptyCheapSearchIsNotCountedAgainstAhealthyTag() { + this.aCheapSearchThatFoundNothing(); + + assertEquals("a tag with a narrow key window must not be penalised for not moving", + 0, this.stored().fruitlessScans); + assertNull(this.stored().ignoredAt); + assertNotNull("the attempt should still be recorded", this.stored().lastScanAt); + } + + /** Two of them, a refresh cycle apart, which is what it now takes to be set aside. */ + private void givenItHasBeenSetAside() { + this.aWideSearchAcrossMonthsThatFoundNothing(); + this.aWideSearchAcrossMonthsThatFoundNothing(); + } + + /** + * Only a search covering months of history counts towards giving up. + * + *

The distinction @parawanderer insisted on: a young tag searching a short history and + * finding nothing means very little, and treating that as death would set aside tags that + * were about to report. + */ + @Test + public void asearchAcrossMonthsOfHistoryWithNothingInItCountsAgainstAtag() { + this.aWideSearchAcrossMonthsThatFoundNothing(); + + assertEquals("a search across months that found nothing was not counted", + 1, this.stored().fruitlessScans); + } + + /** + * But one of them is not enough to retire it. + * + *

Being set aside is close to permanent - every automatic fetch skips it afterwards, and + * it only comes back if somebody opens the tag and asks - so one bad search is a thin basis. + * A fetch can come back empty for reasons that are nothing to do with the tag: a request that + * failed, an account briefly unhappy, a moment when Apple returned nothing. + * + *

Three of @parawanderer's own devices were retired on a single first pass, which is what + * prompted this. + */ + @Test + public void onesearchAcrossMonthsIsNotEnoughToGiveUp() { + this.aWideSearchAcrossMonthsThatFoundNothing(); + + assertNull("a tag was set aside on the strength of a single search", + this.stored().ignoredAt); + } + + /** The second one does, and the two are a refresh cycle apart rather than back to back. */ + @Test + public void asecondSearchAcrossMonthsGivesUp() { + this.givenItHasBeenSetAside(); + + assertNotNull("a tag silent across its whole history twice running should be set aside", + this.stored().ignoredAt); + } + + /** + * And anything found in between calls it off. + * + *

The point of asking twice. A tag that answers on the second attempt has to end up + * indistinguishable from one that never missed, rather than carrying a strike towards a + * retirement it no longer deserves. + */ + @Test + public void areportBetweenTheTwoSearchesCancelsIt() { + this.aWideSearchAcrossMonthsThatFoundNothing(); + this.aSearchThatFoundSomething(); + this.aWideSearchAcrossMonthsThatFoundNothing(); + + assertNull("a tag that reported in between was still retired on the next miss", + this.stored().ignoredAt); + assertEquals("the earlier miss should have been forgotten", 1, + this.stored().fruitlessScans); + } + + /** And being given up on is undone by anything at all being found later. */ + @Test + public void agivenUpTagComesBackTheMomentItIsFound() { + this.givenItHasBeenSetAside(); + assertNotNull(this.stored().ignoredAt); + + this.aSearchThatFoundSomething(); + + assertNull("a tag that reported must stop being ignored", this.stored().ignoredAt); + assertEquals(0, this.stored().fruitlessScans); + } + + // ------------------------------------------------------------- what the scheduler asks for + + private List scheduledRequests() { + return this.repo.toScheduledAccessoryRequests( + BeaconRepository.plistFallback(A_TAG, A_PLIST)).blockingFirst(); + } + + private List manualRequests() { + return this.repo.toAccessoryRequests( + BeaconRepository.plistFallback(A_TAG, A_PLIST)).blockingFirst(); + } + + /** A healthy tag is asked about. */ + @Test + public void ahealthyTagIsIncludedInTheScheduledFetch() { + assertEquals(1, this.scheduledRequests().size()); + } + + /** A tag given up on is skipped entirely - it is the expensive one. */ + @Test + public void agivenUpTagIsSkippedByTheScheduledFetch() { + this.givenItHasBeenSetAside(); + + assertTrue("an ignored tag must not be searched for automatically", + this.scheduledRequests().isEmpty()); + } + + /** And so is one still inside its backoff. */ + @Test + public void atagInsideItsBackoffIsSkippedByTheScheduledFetch() { + for (int i = 0; i < 4; i++) { + this.aWideSearchThatFoundNothing(); + } + assertTrue("this test needs a backoff long enough to still be waiting", + WideScanBackoff.waitMillisAfter(this.stored().fruitlessScans) > 0); + + assertTrue(this.scheduledRequests().isEmpty()); + } + + /** + * But a manual refresh asks anyway. + * + *

The property that keeps this feature from becoming its own bug. Somebody who opens a tag + * and presses "check now" has just overridden the app's judgement, which they are entitled to + * do - they may have found the thing. Backing off a button somebody pressed would look + * exactly like the app ignoring them. + */ + @Test + public void amanualRefreshIsNeverThrottledOrSkipped() { + this.givenItHasBeenSetAside(); + assertTrue("premise: the scheduler has given up on it", + this.scheduledRequests().isEmpty()); + + assertEquals("a tag somebody asked about must always be asked about", + 1, this.manualRequests().size()); + } +} 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 d66d5e84..aca0faca 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 @@ -228,6 +228,162 @@ public void migrate1To3_directUpgradePreservesEverything() throws IOException { } } + /** + * v3 → v4 adds {@code from_account}, and every existing row must come out as a file import. + * + *

This is the assertion that protects everybody's tags. Refreshing from the Apple + * account deletes account beacons that are no longer on it; a pre-existing row defaulting to + * "from the account" would therefore be deleted the first time somebody fetched - and those + * rows are the only copy that exists, since the export they came from may be long gone. + */ + @Test + public void migrate3To4_existingBeaconsAreNotTreatedAsAccountBeacons() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { + insertImport(db, 1L); + insertOwnedBeaconV1(db, BEACON_ID, 1L, BEACON_PLIST, false); + } + + helper.runMigrationsAndValidate(TEST_DB, 2, true, OpenTagViewerDatabase.MIGRATION_1_2); + helper.runMigrationsAndValidate(TEST_DB, 3, true, OpenTagViewerDatabase.MIGRATION_2_3); + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 4, true, OpenTagViewerDatabase.MIGRATION_3_4); + + try (Cursor cursor = db.query( + "SELECT content, from_account FROM OwnedBeacons WHERE id = ?", + new Object[]{BEACON_ID})) { + assertTrue("beacon row did not survive v3 to v4", cursor.moveToFirst()); + assertEquals(BEACON_PLIST, cursor.getString(0)); + assertEquals( + "an existing beacon must be a file import, or a refresh will delete it", + 0, cursor.getInt(1)); + } + } + + /** + * v4 to v5, from a v1 database - the path a long-standing user actually takes. + * + *

Users skip releases, so the only migration that matters is the whole chain. What must + * survive is the beacon itself; what must be true afterwards is that it looks healthy - + * never scanned, nothing held against it, not ignored - because the alternative is an + * upgrade that silently decides somebody's tags have stopped broadcasting and stops looking + * for them. + */ + @Test + public void migrate4To5_existingBeaconsStartHealthyRatherThanIgnored() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { + insertImport(db, 1L); + insertOwnedBeaconV1(db, BEACON_ID, 1L, BEACON_PLIST, false); + } + + helper.runMigrationsAndValidate(TEST_DB, 2, true, OpenTagViewerDatabase.MIGRATION_1_2); + helper.runMigrationsAndValidate(TEST_DB, 3, true, OpenTagViewerDatabase.MIGRATION_2_3); + helper.runMigrationsAndValidate(TEST_DB, 4, true, OpenTagViewerDatabase.MIGRATION_3_4); + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 5, true, OpenTagViewerDatabase.MIGRATION_4_5); + + try (Cursor cursor = db.query( + "SELECT content, fruitless_scans, last_scan_at, ignored_at FROM OwnedBeacons" + + " WHERE id = ?", + new Object[]{BEACON_ID})) { + assertTrue("beacon row did not survive v4 to v5", cursor.moveToFirst()); + assertEquals(BEACON_PLIST, cursor.getString(0)); + assertEquals("an upgraded beacon must not start with strikes against it", + 0, cursor.getInt(1)); + assertTrue("an upgraded beacon must look never-scanned", cursor.isNull(2)); + assertTrue("an upgrade must never mark somebody's tag as ignored", cursor.isNull(3)); + } + } + + /** And a soft-deleted row still survives the whole chain, as it does at every other step. */ + @Test + public void migrate4To5_preservesRemovedBeaconsToo() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { + insertImport(db, 1L); + insertOwnedBeaconV1(db, "beacon-a", 1L, BEACON_PLIST, false); + insertOwnedBeaconV1(db, "beacon-b", 1L, BEACON_PLIST, true); + } + + helper.runMigrationsAndValidate(TEST_DB, 2, true, OpenTagViewerDatabase.MIGRATION_1_2); + helper.runMigrationsAndValidate(TEST_DB, 3, true, OpenTagViewerDatabase.MIGRATION_2_3); + helper.runMigrationsAndValidate(TEST_DB, 4, true, OpenTagViewerDatabase.MIGRATION_3_4); + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 5, true, OpenTagViewerDatabase.MIGRATION_4_5); + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM OwnedBeacons")) { + assertTrue(cursor.moveToFirst()); + assertEquals("beacons were lost during v4 to v5", 2, cursor.getInt(0)); + } + } + + @Test + public void migrate4To5_handlesEmptyDatabase() throws IOException { + helper.createDatabase(TEST_DB, 1).close(); + + helper.runMigrationsAndValidate(TEST_DB, 2, true, OpenTagViewerDatabase.MIGRATION_1_2); + helper.runMigrationsAndValidate(TEST_DB, 3, true, OpenTagViewerDatabase.MIGRATION_2_3); + helper.runMigrationsAndValidate(TEST_DB, 4, true, OpenTagViewerDatabase.MIGRATION_3_4); + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 5, true, OpenTagViewerDatabase.MIGRATION_4_5); + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM OwnedBeacons")) { + assertTrue(cursor.moveToFirst()); + assertEquals(0, cursor.getInt(0)); + } + } + + @Test + public void migrate3To4_handlesEmptyDatabase() throws IOException { + helper.createDatabase(TEST_DB, 1).close(); + + helper.runMigrationsAndValidate(TEST_DB, 2, true, OpenTagViewerDatabase.MIGRATION_1_2); + helper.runMigrationsAndValidate(TEST_DB, 3, true, OpenTagViewerDatabase.MIGRATION_2_3); + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 4, true, OpenTagViewerDatabase.MIGRATION_3_4); + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM OwnedBeacons")) { + assertTrue(cursor.moveToFirst()); + assertEquals(0, cursor.getInt(0)); + } + } + + /** + * Straight from v1 to v4, which is what somebody who skipped two releases actually does. + * + *

Users skip releases, so the sequential path being right is not enough on its own. + */ + @Test + public void migrate1To4_directUpgradePreservesEverything() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { + insertImport(db, 1L); + insertOwnedBeaconV1(db, "beacon-a", 1L, BEACON_PLIST, false); + insertOwnedBeaconV1(db, "beacon-b", 1L, BEACON_PLIST, true); + insertLocationReport(db, "hash-1", "beacon-a", 1700000000000L); + } + + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 4, true, + OpenTagViewerDatabase.MIGRATION_1_2, + OpenTagViewerDatabase.MIGRATION_2_3, + OpenTagViewerDatabase.MIGRATION_3_4); + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM OwnedBeacons")) { + assertTrue(cursor.moveToFirst()); + assertEquals("beacons lost on a direct v1 to v4 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 v4 upgrade", 1, cursor.getInt(0)); + } + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM OwnedBeacons WHERE from_account = 1")) { + assertTrue(cursor.moveToFirst()); + assertEquals( + "nothing that existed before the account route may be marked as coming from it", + 0, cursor.getInt(0)); + } + } + private static void insertImport(SupportSQLiteDatabase db, long id) { ContentValues values = new ContentValues(); values.put("id", id); diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/AccountRefresherTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/AccountRefresherTest.java new file mode 100644 index 00000000..af66e431 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/AccountRefresherTest.java @@ -0,0 +1,187 @@ +package dev.wander.android.opentagviewer.python.icloud; + +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.List; + +import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; +import dev.wander.android.opentagviewer.db.repo.BeaconRepository; +import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; + +/** + * Re-reading the Apple account with nobody watching. + * + *

This is what joining the keychain was for. Membership buys reading without a device + * passcode, and until now the only thing that spent it was somebody opening a screen and asking - + * so a tag added in Find My, renamed there, or removed did not reach the app until the user went + * hunting for a button. + * + *

Everything here is silent in production: it succeeds by the device list quietly being right + * later on, and fails by logging. That is exactly why it needs tests - there is no screen to + * notice on, and the two failure modes matter in opposite directions. A read that gives up too + * easily leaves the app stale forever; one that never gives up retries dead keys on every + * interval and never says why. + */ +@RunWith(AndroidJUnit4.class) +public class AccountRefresherTest { + + private OpenTagViewerDatabase db; + private BeaconRepository beacons; + private KeychainMembershipRepository memberships; + private FakeICloudService icloud; + + @Before + public void openEverything() { + this.db = Room.inMemoryDatabaseBuilder( + getInstrumentation().getTargetContext(), OpenTagViewerDatabase.class) + .allowMainThreadQueries() + .build(); + + // The real converter needs a running Python runtime; nothing here is about conversion. + this.beacons = new BeaconRepository(this.db, (plist, alignment) -> "{\"type\":\"a\"}"); + + this.memberships = new KeychainMembershipRepository( + UserAuthDataStore.getInstance(getInstrumentation().getTargetContext()), + new AppCryptographyUtil()); + this.memberships.forget().blockingAwait(); + + this.icloud = FakeICloudService.withTags(); + AppDependencies.replaceICloud(() -> this.icloud); + } + + @After + public void closeEverything() { + AppDependencies.reset(); + this.memberships.forget().blockingAwait(); + this.db.close(); + } + + private void givenTheAccountIsLinked() { + this.memberships.store(new KeychainMembership( + "{\"peer_id\":\"peer-ours\"}", "ZW50cm9weQ==", "a-passcode", "a-label", 2)) + .blockingAwait(); + } + + private AccountRefresher aRefresher() { + return new AccountRefresher(this.memberships, this.beacons); + } + + private List liveBeaconIds() { + return this.db.ownedBeaconDao().getAll().stream().map(b -> b.id).collect( + java.util.stream.Collectors.toList()); + } + + /** A linked account is read and the tags land in the database. */ + @Test + public void alinkedAccountIsReadWithoutAnybodyAsking() { + this.givenTheAccountIsLinked(); + + final List held = this.aRefresher().refresh().blockingFirst(); + + assertFalse("nothing was read from the account", held.isEmpty()); + assertEquals("what was read should be what is stored", + held.size(), this.liveBeaconIds().size()); + } + + /** + * And it never asks for a device passcode. + * + *

The whole point. A background read that could ask for one would be a background read + * that stalls forever, because there is nobody there to answer. + */ + @Test + public void itreadsAsTheMemberItAlreadyIsRatherThanUnlocking() { + this.givenTheAccountIsLinked(); + + this.aRefresher().refresh().blockingFirst(); + + assertEquals("a background read must never unlock", 0, this.icloud.timesCalled("unlock")); + assertEquals("it should resume as the stored member", 1, this.icloud.timesCalled("resume")); + } + + /** An account nobody linked is not an error, and not worth a log line every interval. */ + @Test + public void anaccountThatWasNeverLinkedIsSkippedQuietly() { + final List held = this.aRefresher().refresh().blockingFirst(); + + assertTrue(held.isEmpty()); + assertEquals("nothing should have been opened", 0, this.icloud.timesCalled("open")); + } + + /** The session is closed even when the read worked - these hold sockets. */ + @Test + public void thesessionIsClosedAfterwards() { + this.givenTheAccountIsLinked(); + + this.aRefresher().refresh().blockingFirst(); + + assertEquals(1, this.icloud.timesCalled("close")); + } + + /** + * A membership the account no longer honours is forgotten. + * + *

Removing this app's peer is how somebody revokes it, and that is a state a real user + * creates deliberately. Keeping the dead membership means retrying keys that cannot work on + * every interval, forever, with nothing on screen ever explaining why the tags stopped + * changing. Forgetting it costs one device passcode and puts the app back where the screens + * can explain themselves. + */ + @Test + public void adeadMembershipIsForgottenRatherThanRetriedForever() { + this.givenTheAccountIsLinked(); + this.icloud = FakeICloudService.withTags().whereTheMembershipNoLongerWorks(); + AppDependencies.replaceICloud(() -> this.icloud); + + this.aRefresher().refresh().blockingFirst(); + + assertTrue("the unusable membership was kept", + this.memberships.get().blockingFirst().isEmpty()); + } + + /** + * Any other failure leaves everything alone. + * + *

A read that could not reach Apple says nothing and changes nothing - the stored tags are + * still the best answer available, and throwing them away because the network was down would + * be losing data to a transient. + */ + @Test + public void afailedReadLeavesTheStoredTagsAlone() { + this.givenTheAccountIsLinked(); + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id("a-stored-tag").content("").version("account") + .fromAccount(true).isRemoved(false).build()); + + // **Failing at the fetch, not at the recovery options.** The first version of this used + // whereTheServiceIsUnsure(), which breaks a call this path never makes - so the read + // succeeded and the test asserted nothing. A background read never asks what could + // unlock the keychain; it resumes and reads. + this.icloud = FakeICloudService.withTags().whereFetchingFails(new ICloudException( + ICloudFailure.UNKNOWN, "the fake was told the account could not be reached")); + AppDependencies.replaceICloud(() -> this.icloud); + + final List held = this.aRefresher().refresh().blockingFirst(); + + assertTrue("a failed read must report nothing rather than throwing", held.isEmpty()); + assertEquals("the stored tags should be untouched", + List.of("a-stored-tag"), this.liveBeaconIds()); + assertTrue("a transient failure must not forget the membership", + this.memberships.get().blockingFirst().isPresent()); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/EscrowPasscodeTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/EscrowPasscodeTest.java new file mode 100644 index 00000000..f05cf8e6 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/EscrowPasscodeTest.java @@ -0,0 +1,107 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.HashSet; +import java.util.Set; + +/** + * The passcode this app's own escrow record is enrolled under. + * + *

Nobody ever sees it, which removes every constraint a human-facing code has and leaves only + * one: that it is genuinely unguessable. These check the properties that would still look fine + * if they were wrong - a generator that repeated itself, or one that quietly produced something + * far shorter than intended, reads identically in a log. + */ +@RunWith(AndroidJUnit4.class) +public class EscrowPasscodeTest { + + @Test + public void itcarriesTheEntropyItClaims() { + assertTrue("256 bits is the point of it", EscrowPasscode.ENTROPY_BYTES * 8 >= 256); + assertTrue(EscrowPasscode.isWellFormed(EscrowPasscode.generate())); + } + + /** + * Every passcode is different. + * + *

The failure this rules out is a generator seeded once, or one accidentally returning a + * constant - both of which produce a perfectly well-formed value that protects nothing, and + * neither of which looks wrong anywhere. + */ + @Test + public void ineverRepeatsItself() { + final Set seen = new HashSet<>(); + + for (int i = 0; i < 500; i++) { + seen.add(EscrowPasscode.generate()); + } + + assertTrue("the generator repeated itself, so it is not random", seen.size() == 500); + } + + @Test + public void twoInARowDiffer() { + assertNotEquals(EscrowPasscode.generate(), EscrowPasscode.generate()); + } + + /** + * It is not a PIN, and the record's metadata says as much. + * + *

Enrolment publishes {@code SecureBackupUsesNumericPassphrase}, computed by asking + * whether every character is a digit. An all-numeric passcode would advertise itself as the + * sort of secret a six-digit code protects. + */ + @Test + public void itisNeverNumeric() { + for (int i = 0; i < 200; i++) { + final String passcode = EscrowPasscode.generate(); + + boolean allDigits = true; + for (int c = 0; c < passcode.length(); c++) { + if (!Character.isDigit(passcode.charAt(c))) { + allDigits = false; + break; + } + } + + assertFalse("a numeric passcode advertises itself as a PIN", allDigits); + } + } + + /** + * It travels as one unbroken token. + * + *

It passes through a property list and an SRP exchange. A newline or padding character in + * the middle is an avoidable variable in a value whose failure mode is indistinguishable from + * Apple refusing the exchange. + */ + @Test + public void itisOneTokenWithNothingToEscape() { + final String passcode = EscrowPasscode.generate(); + + assertFalse(passcode.contains("\n")); + assertFalse(passcode.contains("=")); + assertFalse(passcode.contains("+")); + assertFalse(passcode.contains("/")); + assertFalse(passcode.contains(" ")); + } + + /** A stored value that came back damaged is caught rather than used. */ + @Test + public void itrejectsWhatIsNotOne() { + assertFalse(EscrowPasscode.isWellFormed(null)); + assertFalse("empty is the one thing enrolment itself refuses", + EscrowPasscode.isWellFormed("")); + assertFalse("a truncated passcode must not pass", + EscrowPasscode.isWellFormed(EscrowPasscode.generate().substring(0, 10))); + assertFalse(EscrowPasscode.isWellFormed("123456")); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/FakeICloudService.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/FakeICloudService.java new file mode 100644 index 00000000..6cde99e1 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/FakeICloudService.java @@ -0,0 +1,385 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Observable; + +/** + * An iCloud that behaves however a test needs it to. + * + *

Every state worth getting right needs an Apple account nobody can arrange. An + * account with no device to recover from, a keychain service having a bad afternoon, a passcode + * refused three times - none of those can be produced on demand, and a working account will + * never be in any of them. So the screen driving them could only ever be checked by reasoning + * about it, which is how a screen ends up telling somebody with a perfectly good account that + * they permanently own no tags. + * + *

Records what it was asked, because several of the mistakes available here are about the + * screen calling the wrong thing rather than drawing the wrong thing: unlocking with a device + * the user did not pick, or spending an attempt on an empty passcode. + */ +public final class FakeICloudService implements ICloudService { + + /** A named iPhone: the ordinary case, where the user renamed their phone. */ + public static final RecoverableDevice AN_IPHONE = new RecoverableDevice( + "F2LX9Q", "Shane’s iPhone, iPhone 15, serial F2LX9Q, escrowed 2024-03-12", + "Shane’s iPhone", "iPhone15,2", "iPhone", 1710201600000L); + + /** A named Mac, so the icon has something to be wrong about. */ + public static final RecoverableDevice A_MAC = new RecoverableDevice( + "C02XK", "Work MacBook, MacBook Pro, serial C02XK, escrowed 2023-11-02", + "Work MacBook", "MacBookPro18,3", "Mac", 1698883200000L); + + /** + * A device nobody ever renamed, which is the case the tile has to not embarrass itself on. + * + *

FindMy.py falls back to the literal "unnamed device"; the screen should say "iPad". + */ + public static final RecoverableDevice AN_UNNAMED_IPAD = new RecoverableDevice( + "DMPX2", "unnamed device, iPad Pro, serial DMPX2, escrowed 2022-06-01", + "", "iPad13,4", "iPad", 1654041600000L); + + public static final ICloudAccessory A_BIKE = new ICloudAccessory( + "F1C4A0E2-1111-4222-8333-444455556666", "Bike", "🚲", "🚲 Bike", + "AirTag, serial HXXXXXXXXXXX, paired 2024-03-01", true, true); + public static final ICloudAccessory A_NAMELESS_ONE = new ICloudAccessory( + "0A0B0C0D-2222-4333-8444-555566667777", null, null, "unnamed", + "AirTag, serial HYYYYYYYYYYY, paired 2023-11-14", false, false); + + private List devices = List.of(AN_IPHONE, A_MAC); + private List accessories = List.of(A_BIKE, A_NAMELESS_ONE); + private List skipped = List.of(); + + private ICloudException openFailsWith; + private ICloudException optionsFailsWith; + private ICloudException unlockFailsWith; + private ICloudException fetchFailsWith; + private ICloudException joinFailsWith; + private ICloudException resumeFailsWith; + private String joinedWithPasscode; + private String resumedWith; + + /** How many times a passcode is refused before it starts being accepted. */ + private int refusalsBeforeAccepting = 0; + private long answerDelayMs = 0; + + private final List calls = new ArrayList<>(); + private final List unlockedWith = new ArrayList<>(); + private String lastPasscode; + private boolean closed; + + /** The ordinary case: two devices to choose from and two tags on the account. */ + public static FakeICloudService withTags() { + return new FakeICloudService(); + } + + /** + * An account with nothing that can unlock its keychain. + * + *

The real class of user this whole flow has to answer for: an Apple ID that has never + * had an iPhone, iPad or Mac on it has never escrowed a keychain, and no amount of retrying + * will change that. + */ + public static FakeICloudService withNothingToRecoverFrom() { + final FakeICloudService fake = new FakeICloudService(); + fake.optionsFailsWith = new ICloudException( + ICloudFailure.NOTHING_TO_RECOVER_FROM, + "No record on this account can currently be recovered from."); + return fake; + } + + /** Nothing reported usable at all, which reads as a service having a bad day. */ + public static FakeICloudService whereTheServiceIsUnsure() { + final FakeICloudService fake = new FakeICloudService(); + fake.optionsFailsWith = new ICloudException( + ICloudFailure.SERVICE_UNSURE, + "Nothing was reported usable at all."); + return fake; + } + + /** An account with a Mac on it and no tags - the empty fetch, one step later. */ + public static FakeICloudService withNoTagsOnTheAccount() { + final FakeICloudService fake = new FakeICloudService(); + fake.accessories = List.of(); + return fake; + } + + /** The passcode is refused this many times, then accepted. */ + public FakeICloudService refusingThePasscode(final int times) { + this.refusalsBeforeAccepting = times; + return this; + } + + /** + * An account with a great many devices, to push the list past the height of the screen. + * + *

The people most likely to have this are the ones most likely to use the app: somebody + * with years of Apple hardware has an escrow record for every one of it, and escrow records + * outlive the devices that made them. + */ + public static FakeICloudService withManyDevices(final int count) { + final FakeICloudService fake = new FakeICloudService(); + final List many = new ArrayList<>(); + for (int i = 0; i < count; i++) { + many.add(new RecoverableDevice( + "SERIAL" + i, "Device " + i + ", serial SERIAL" + i, + "Device " + i, "iPhone15,2", "iPhone", 1710201600000L)); + } + fake.devices = many; + return fake; + } + + /** Only one device can be recovered from, so there is nothing to choose between. */ + public FakeICloudService withOneDevice() { + this.devices = List.of(AN_IPHONE); + return this; + } + + /** Some records were set aside for being the account's own hardware. */ + public FakeICloudService alsoSkipping(final String... names) { + final List setAside = new ArrayList<>(); + for (final String name : names) { + setAside.add(new ICloudFetch.SkippedAccessory( + name, "no private key, so it is a device rather than a tag")); + } + this.skipped = setAside; + return this; + } + + public FakeICloudService whereFetchingFails(final ICloudException failure) { + this.fetchFailsWith = failure; + return this; + } + + @Override + public Completable open() { + this.calls.add("open"); + return this.openFailsWith == null + ? Completable.complete() : Completable.error(this.openFailsWith); + } + + @Override + public Observable> recoveryOptions() { + this.calls.add("recoveryOptions"); + if (this.optionsFailsWith != null) { + return Observable.error(this.optionsFailsWith); + } + + final Observable> answer = Observable.just(this.devices); + + // A real account takes a moment. Everything else here is instant, which is right for a + // test and useless for looking at the screen somebody sees while they wait. + return this.answerDelayMs > 0 + ? answer.delay(this.answerDelayMs, java.util.concurrent.TimeUnit.MILLISECONDS) + : answer; + } + + /** Take this long to answer, so the waiting screen can be seen or captured. */ + public FakeICloudService takingItsTime(final long millis) { + this.answerDelayMs = millis; + return this; + } + + @Override + public Completable unlock(final String serial, final String passcode) { + this.calls.add("unlock"); + this.unlockedWith.add(serial); + this.lastPasscode = passcode; + + if (this.unlockFailsWith != null) { + return Completable.error(this.unlockFailsWith); + } + + if (this.timesCalled("unlock") <= this.refusalsBeforeAccepting) { + return Completable.error(new ICloudException( + ICloudFailure.PASSCODE_REJECTED, + "That was not accepted. Worth trying the same passcode again.")); + } + + return Completable.complete(); + } + + @Override + public Observable join(final String escrowPasscode) { + this.calls.add("join"); + this.joinedWithPasscode = escrowPasscode; + + if (this.joinFailsWith != null) { + return Observable.error(this.joinFailsWith); + } + + return Observable.just(new KeychainMembership( + "{\"peer_id\":\"peer-ours\"}", "ZW50cm9weQ==", escrowPasscode, "a-label", 2)); + } + + @Override + public Completable resume(final String peerJson) { + this.calls.add("resume"); + this.resumedWith = peerJson; + + return this.resumeFailsWith == null + ? Completable.complete() : Completable.error(this.resumeFailsWith); + } + + @Override + public Completable rename(final String beaconId, final String plistXml, + final String name, final String emoji) { + this.calls.add("rename"); + this.renamedWith = new String[] {beaconId, name, emoji}; + this.renamedPlist = plistXml; + + return this.renameFailsWith == null + ? Completable.complete() : Completable.error(this.renameFailsWith); + } + + /** What the last rename was asked to change: beacon id, name, emoji. Null if never called. */ + public String[] renamedWith() { + return this.renamedWith; + } + + /** + * The record the rename was judged from. + * + *

Worth asserting on rather than ignoring: Python decides accessory-or-device from this + * plist, so a screen that sends the wrong one - or an empty one - would have Python answering + * about a different tag entirely, and the rename would still look like it worked. + */ + public String renamedPlist() { + return this.renamedPlist; + } + + /** The account refuses the write - the network is down, or the session lost its keys. */ + public FakeICloudService whereRenamingFails(final ICloudFailure failure) { + this.renameFailsWith = new ICloudException(failure, "the fake was told to refuse"); + return this; + } + + /** The stored membership has stopped working - the peer was removed from the account. */ + public FakeICloudService whereTheMembershipNoLongerWorks() { + this.resumeFailsWith = new ICloudException( + ICloudFailure.MEMBERSHIP_UNUSABLE, "no such peer"); + return this; + } + + public FakeICloudService whereJoiningFails(final ICloudException failure) { + this.joinFailsWith = failure; + return this; + } + + /** The passcode the app generated for its own record, so a test can check it was a real one. */ + public String joinedWithPasscode() { + return this.joinedWithPasscode; + } + + public String resumedWith() { + return this.resumedWith; + } + + @Override + public Observable fetch() { + this.calls.add("fetch"); + return this.fetchFailsWith == null + ? Observable.just(new ICloudFetch(this.accessories, this.skipped)) + : Observable.error(this.fetchFailsWith); + } + + + /** + * A record the real converter can actually convert. + * + *

It used to be the string {@code ""}, which is not a small shortcut: the + * app hands whatever comes back to Python's {@code convertPlistToJson}, and an empty document + * fails there with {@code 'NoneType' object is not subscriptable}. The failure is swallowed by + * design - a tag whose accessory JSON is missing is backfilled on first fetch - so every test + * that "imported" a tag from the account was quietly writing rows with no accessory state at + * all, and nothing downstream of that conversion was being exercised by anything. + * + *

The key material is the committed fixture's, so it is real in shape - a 28-byte master + * key and two 32-byte shared secrets, which is what {@code FindMyAccessory.from_plist} + * reaches for - and secret in no sense whatsoever. + */ + public static final String AN_OWNED_BEACON_PLIST = + "" + + "" + + "batteryLevel1" + + "identifierF612A183-492B-45A8-A5A2-233CA9062A94" + + "model" + + "pairingDate2025-02-27T20:03:32Z" + + "privateKeykeydata" + + "J1AAk7qStLSbMhZT/XEve6by7hI0H7CslD/Oh7SrOc+mlmLnAO8c" + + "5FGnhi/s3TDlWNiL3SMy19NQuCWg6oTS+YfBZN79RiUmZtssTp9f" + + "UvZjmqMX3g==" + + "" + + "productId21760" + + "publicKeykeydata" + + "k6fWaOxFGbClYV6tu/ZK4vXdyWl2joSbJhbzu12Pfmf5p09w5LxKIvnABRfysSFkOAlo/F3Ii9Dq" + + "" + + "secondarySharedSecretkeydata" + + "1pWMT+FI3flAWmgbUEW5H6omZy+yZOzp30zZGxEa2A8=" + + "sharedSecretkeydata" + + "vM2ZjU/sKW/novHcwzTlY5xwGLOUOZjpgcZa9cNx2Y8=" + + "stableIdentifier" + + "2001~#001234a12345aaac~#A02BCDEFG1AB" + + "systemVersion2.0.73" + + "vendorId76" + + ""; + + /** Named after whatever the screen already calls it, so a list has readable rows. */ + private static String namingRecordFor(final String beaconId) { + final String name = A_BIKE.getBeaconId().equals(beaconId) ? A_BIKE.getName() : "Keys"; + + return "" + + "identifier" + beaconId + "" + + "associatedBeacon" + beaconId + "" + + "name" + name + "" + + ""; + } + + @Override + public Observable> records(final List beaconIds) { + this.calls.add("records"); + + final List taken = new ArrayList<>(); + for (final String beaconId : beaconIds) { + taken.add(new AccessoryRecords( + beaconId, AN_OWNED_BEACON_PLIST, namingRecordFor(beaconId), null)); + } + + return Observable.just(taken); + } + + @Override + public void close() { + this.calls.add("close"); + this.closed = true; + } + + public List calls() { + return this.calls; + } + + private String[] renamedWith; + + private String renamedPlist; + + private ICloudException renameFailsWith; + + public long timesCalled(final String call) { + return this.calls.stream().filter(call::equals).count(); + } + + /** Which devices' passcodes were offered, in order. */ + public List unlockedWith() { + return this.unlockedWith; + } + + public String lastPasscode() { + return this.lastPasscode; + } + + public boolean wasClosed() { + return this.closed; + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailureWireTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailureWireTest.java new file mode 100644 index 00000000..c3763947 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailureWireTest.java @@ -0,0 +1,103 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.chaquo.python.PyObject; +import com.chaquo.python.Python; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.List; + +/** + * That the two sides of the bridge mean the same things by the same strings. + * + *

The failure this exists for does not throw. A reason added in Python and not added + * here does not break anything visibly - {@link ICloudFailure#fromWire} deliberately falls back + * to {@link ICloudFailure#UNKNOWN} rather than exploding, because a new failure mode must not + * become a crash on the screen that reports failures. The cost is paid quietly instead: the user + * gets a generic "something went wrong" where a specific, actionable screen was written for them. + * + *

The one that would hurt most is {@code service_unsure} degrading to {@code UNKNOWN}, because + * the specific screen for it is the one that says "try again later" rather than "this account + * owns no tags" - the difference between a user coming back tomorrow and a user giving up. + * + *

So this walks the module rather than a list written by hand. A list would have to be + * updated by the same person who forgot to update the enum. + */ +@RunWith(AndroidJUnit4.class) +public class ICloudFailureWireTest { + + private static final String MODULE = "icloud_bridge"; + + private static List reasonConstants() { + final Python py = Python.getInstance(); + final PyObject module = py.getModule(MODULE); + + final List found = new ArrayList<>(); + for (final PyObject name : py.getBuiltins().callAttr("dir", module).asList()) { + if (name.toString().startsWith("REASON_")) { + found.add(name.toString()); + } + } + + return found; + } + + @Test + public void everyReasonPythonCanReportHasAScreenBehindIt() { + final Python py = Python.getInstance(); + final PyObject module = py.getModule(MODULE); + final List constants = reasonConstants(); + + // Without this the whole test passes by finding nothing, which is the failure mode of + // every test written against reflection. + assertTrue("found no REASON_ constants at all - this test is checking nothing", + constants.size() >= 5); + + for (final String constant : constants) { + final String wire = module.get(constant).toString(); + final ICloudFailure mapped = ICloudFailure.fromWire(wire); + + if ("REASON_UNKNOWN".equals(constant)) { + assertEquals("REASON_UNKNOWN is the one that is meant to land there", + ICloudFailure.UNKNOWN, mapped); + continue; + } + + assertNotEquals( + constant + " (\"" + wire + "\") has no case in ICloudFailure.fromWire, so the" + + " screen written for it will never be shown", + ICloudFailure.UNKNOWN, mapped); + } + } + + @Test + public void thetwoEmptyAnswersStayDistinct() { + // Asserted on the wire values themselves rather than the enum, because collapsing them + // is a one-character edit on either side and only this compares the two. + final PyObject module = Python.getInstance().getModule(MODULE); + + final ICloudFailure nothing = + ICloudFailure.fromWire(module.get("REASON_NOTHING_TO_RECOVER_FROM").toString()); + final ICloudFailure unsure = + ICloudFailure.fromWire(module.get("REASON_SERVICE_UNSURE").toString()); + + assertEquals(ICloudFailure.NOTHING_TO_RECOVER_FROM, nothing); + assertEquals(ICloudFailure.SERVICE_UNSURE, unsure); + assertNotEquals("an account with no Apple device and a service outage are not the same" + + " thing, and only one of them is worth coming back for", nothing, unsure); + } + + @Test + public void anunrecognisedReasonDegradesRatherThanThrowing() { + assertEquals(ICloudFailure.UNKNOWN, ICloudFailure.fromWire("something_added_later")); + assertEquals(ICloudFailure.UNKNOWN, ICloudFailure.fromWire(null)); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/OpeningARealICloudSessionTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/OpeningARealICloudSessionTest.java new file mode 100644 index 00000000..745c372b --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/OpeningARealICloudSessionTest.java @@ -0,0 +1,128 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import com.chaquo.python.Kwarg; +import com.chaquo.python.PyObject; +import com.chaquo.python.Python; +import com.chaquo.python.android.AndroidPlatform; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.python.PythonAppleAccount; + +/** + * Opening the real iCloud session, across the real Chaquopy bridge. + * + *

This exists because a bug shipped straight through a green suite. The check for "did + * the bridge give us a session" read {@code made == null || made.toJava(Object.class) == null}, + * which looks like careful null handling and is a guaranteed failure on the path where a session + * was created: Chaquopy cannot convert an arbitrary Python object to {@code + * java.lang.Object} and throws {@link ClassCastException}. The real iCloud flow was therefore + * dead on every device, and the screen reported it as "no signed-in account" - a cause it had + * invented - so it looked like a button that did nothing. + * + *

Nothing could have caught it, because every other test replaces this class. + * {@code FakeICloudService} is what all the screen tests drive, which is right for testing + * screens and means {@code PythonICloudService} itself had never run outside somebody's hands. + * The lesson is @parawanderer's: everything external here is behind Python, so a fake on the Java + * side of the bridge skips the bridge. + * + *

What is pinned here is small and exact - the two facts the bug hinged on - and no Apple + * account is needed for either. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class OpeningARealICloudSessionTest { + + @BeforeClass + public static void startPython() { + if (!Python.isStarted()) { + Python.start(new AndroidPlatform(getInstrumentation().getTargetContext())); + } + } + + /** + * Python's {@code None} arrives as Java null. + * + *

Which is why {@code made == null} is the entire check. {@code openSession} refuses an + * account whose internals it does not recognise by returning {@code None}, and here that is + * everything Java needs to see. + */ + @Test + public void refusingToOpenAsessionComesBackAsNull() { + final PyObject refused = Python.getInstance() + .getModule("icloud_bridge") + .callAttr("openSession", (Object) null); + + assertNull("a Python None must reach Java as null, not as a PyObject wrapping None", + refused); + } + + /** + * And converting a Python object to {@code java.lang.Object} throws. + * + *

The other half. A session that opened is a Python instance with no Java equivalent, so + * asking Chaquopy to convert it is not a defensive extra check - it is the failure. Pinned + * against the bridge module's own class, which is exactly the kind of object + * {@code openSession} returns on success. + */ + @Test + public void aPythonObjectCannotBeConvertedToJavaObject() { + final PyObject aPythonThing = Python.getInstance() + .getModule("icloud_bridge") + .get("ICloudSession"); + + assertNotNull("the bridge module should expose its session class", aPythonThing); + assertThrows("converting a Python object to java.lang.Object has to throw, or this test" + + " is not pinning the trap it was written for", + ClassCastException.class, () -> aPythonThing.toJava(Object.class)); + } + + /** + * And the service handles a useless account by returning null rather than throwing. + * + *

The caller treats null as "sign in again", so a throw here would be a crash on a screen + * whose whole job is reporting that something cannot be done. + */ + @Test + public void anaccountTheBridgeCannotUseYieldsNullRatherThanACrash() { + assertNull(PythonICloudService.openFor(new PythonAppleAccount(null))); + } + + /** + * The one that actually catches the bug: a session that opens comes back. + * + *

The two tests above pin the facts the failure hinged on and would both have passed while + * it shipped - and so would the one above this, because a null account is refused before the + * conversion is ever reached. Only the success path throws, so only the success path catches + * it. Reinstating {@code made.toJava(Object.class)} turns this red and nothing else. + * + *

The account is assembled here rather than mocked at the Java seam, which is the whole + * point: {@code openSession} looks for the two private attributes FindMy.py's account carries, + * and a {@code SimpleNamespace} with those is enough to get a real {@code ICloudSession} back + * across the real bridge. No Apple account, no network, and no fake standing where the bug was. + */ + @Test + public void asessionThatOpensIsHandedBackRatherThanLostInConversion() { + final Python python = Python.getInstance(); + + // What `openSession` guards on: an async account and a loop to drive it. Nothing is + // called on either here - opening a session only stores them. + final PyObject account = python.getModule("types").callAttr( + "SimpleNamespace", + new Kwarg("_asyncacc", python.getModule("types").callAttr("SimpleNamespace")), + new Kwarg("_evt_loop", python.getModule("asyncio").callAttr("new_event_loop"))); + + assertNotNull("a session that opened must reach Java, not be lost converting it", + PythonICloudService.openFor(new PythonAppleAccount(account))); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/TheWholeICloudFlowAcrossTheBridgeTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/TheWholeICloudFlowAcrossTheBridgeTest.java new file mode 100644 index 00000000..05803c1d --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/TheWholeICloudFlowAcrossTheBridgeTest.java @@ -0,0 +1,297 @@ +package dev.wander.android.opentagviewer.python.icloud; + +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 com.chaquo.python.PyObject; +import com.chaquo.python.Python; +import com.chaquo.python.android.AndroidPlatform; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.List; + +import dev.wander.android.opentagviewer.python.PythonAppleAccount; + +/** + * The iCloud flow end to end, with only the network replaced. + * + *

Every other test of this flow replaces the Java side of the bridge, so the bridge never + * runs. {@code FakeICloudService} is what the screen tests drive - correct for testing + * screens, and it means {@code PythonICloudService} itself, the JSON it builds, the objects it + * converts and the reason strings it maps had never executed outside somebody's hands. Two bugs + * shipped straight through that gap and were found by @parawanderer using the app: + * + *

+ * + *

Both lived in the seam between the two languages, which is the one place a fake on either + * side cannot see. So this fakes neither: {@code icloud_test_double} replaces the two functions + * in {@code exporter.icloud} that talk to Apple, and everything above them - the session, the + * JSON, the plist rendering, the failure mapping - is the shipping code. + * + *

The double lives in the debug source set, so it is in the APK the instrumented tests run + * against and in no release build. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class TheWholeICloudFlowAcrossTheBridgeTest { + + private static final String DOUBLE = "icloud_test_double"; + + /** The serial the double escrows against, and the passcode it accepts for it. */ + private static final String A_SERIAL = "F2LX9Q"; + private static final String THE_RIGHT_PASSCODE = "123456"; + + private PyObject theDouble; + private PythonICloudService service; + + @BeforeClass + public static void startPython() { + if (!Python.isStarted()) { + Python.start(new AndroidPlatform(getInstrumentation().getTargetContext())); + } + } + + @Before + public void replaceOnlyTheNetwork() { + this.theDouble = Python.getInstance().getModule(DOUBLE); + this.theDouble.callAttr("install"); + + // The account object comes from the double because opening a session guards on the two + // private attributes FindMy.py's account carries. Everything after this is production. + this.service = PythonICloudService.openFor( + new PythonAppleAccount(this.theDouble.callAttr("anAccount"))); + } + + @After + public void putTheRealOnesBack() { + if (this.service != null) { + this.service.close(); + } + if (this.theDouble != null) { + this.theDouble.callAttr("uninstall"); + } + } + + /** + * Open, then list - because unlocking needs the listing to have happened. + * + *

Not incidental setup. {@code unlock} takes a serial and resolves it against the + * escrow records the session cached while listing, so calling it without listing first fails + * with {@code NO_SUCH_RECORD}. That is the real order too - a user sees their devices, picks + * one, and types its passcode - and it is worth a named method rather than a stray line, + * because the failure when it is missing reads like the fake being wrong rather than the + * caller. + */ + private void openAndListTheRecoverableDevices() { + this.service.open().blockingAwait(); + this.service.recoveryOptions().blockingFirst(); + } + + /** What the fake was asked to do, as one flattened string per key. */ + private String reached(final String key) { + return String.valueOf(this.theDouble.callAttr("whatReachedTheAccount") + .asMap() + .get(PyObject.fromJava(key))); + } + + /** + * A session opens at all. + * + *

The regression for the {@code toJava(Object.class)} bug, driven the way the app drives + * it rather than by asserting on Chaquopy's conversion rules. {@code openFor} returning + * something usable here is what was false on every device. + */ + @Test + public void asessionOpensAndTheServiceIsUsable() { + assertNotNull("openFor refused a session the bridge did create", this.service); + + this.service.open().blockingAwait(); + } + + /** The recoverable devices come back as Java objects with their fields intact. */ + @Test + public void therecoverableDevicesCrossTheBridge() { + this.service.open().blockingAwait(); + + final List devices = this.service.recoveryOptions().blockingFirst(); + + assertEquals(2, devices.size()); + assertEquals(A_SERIAL, devices.get(0).getSerial()); + assertTrue("the description did not survive the bridge: " + devices.get(0).getDescription(), + devices.get(0).getDescription().contains("iPhone")); + } + + /** + * Unlocking passes the passcode through, and joining comes back with a usable membership. + * + *

The membership is what the app stores and cannot regenerate, so "it came back populated" + * is what matters rather than any particular value in it. + */ + @Test + public void unlockingAndJoiningProduceAmembership() { + this.openAndListTheRecoverableDevices(); + this.service.unlock(A_SERIAL, THE_RIGHT_PASSCODE).blockingAwait(); + + assertTrue("the passcode did not reach the keychain: " + this.reached("unlockedWith"), + this.reached("unlockedWith").contains(A_SERIAL + ":" + THE_RIGHT_PASSCODE)); + + final KeychainMembership held = this.service.join("an-escrow-passcode").blockingFirst(); + + assertNotNull(held); + assertTrue("the peer JSON is empty, so nothing could be resumed later", + held.getPeerJson().contains("peer-ours")); + assertEquals("an-escrow-passcode", held.getEscrowPasscode()); + assertTrue("the entropy did not survive", held.getEntropy().length() > 0); + } + + /** + * The join is attributed to the app's one device identity. + * + *

Rule 11: the serial is what distinguishes peers in the trust circle, and the only field + * of this the user actually sees - in a list next to a Remove from Account button. A + * path that composed its own would register a second device. + */ + @Test + public void thejoinCarriesTheAppsOwnSerial() { + this.openAndListTheRecoverableDevices(); + this.service.unlock(A_SERIAL, THE_RIGHT_PASSCODE).blockingAwait(); + this.service.join("an-escrow-passcode").blockingFirst(); + + assertEquals("the peer was registered under something other than the app's serial", + "0PENTAGVIEWR", this.reached("joinedSerial")); + } + + /** + * A wrong passcode arrives as a rejected passcode, not as a crash. + * + *

Mapping FindMy.py's {@code RecoveryError} to something a screen can phrase is pure + * bridge code, on the path a user reaches by mistyping - which is to say the common one. + */ + @Test + public void arejectedPasscodeIsReportedAsRejected() { + this.service.open().blockingAwait(); + + this.service.recoveryOptions().blockingFirst(); + + Throwable failure = null; + try { + this.service.unlock(A_SERIAL, "000000").blockingAwait(); + } catch (final Throwable thrown) { + failure = thrown; + } + + assertNotNull("a wrong passcode was accepted", failure); + + final Throwable cause = failure.getCause() != null ? failure.getCause() : failure; + final String described = String.valueOf(cause.getMessage()); + + assertFalse("the failure reached Java as a raw traceback rather than a reason: " + + described, described.contains("Traceback")); + } + + /** + * Fetching lists what the account holds, and says what it skipped. + * + *

The picker is built from this, so a dropped {@code skipped} entry means somebody's iPad + * silently vanishes with no explanation of why. + */ + @Test + public void fetchingListsTheAccessoriesAndKeepsWhatWasSkipped() { + this.service.open().blockingAwait(); + + final ICloudFetch fetched = this.service.fetch().blockingFirst(); + + assertEquals(2, fetched.getAccessories().size()); + + final ICloudAccessory bike = fetched.getAccessories().get(0); + assertEquals("a-bike-tag", bike.getBeaconId()); + assertEquals("Bike", bike.getName()); + assertEquals("🚲", bike.getEmoji()); + assertTrue("a tag with an alignment record was reported as lacking one", + bike.isHasAlignment()); + + // A tag CloudKit holds no naming record for is a real case, not an error. + final ICloudAccessory nameless = fetched.getAccessories().get(1); + assertEquals("a-nameless-tag", nameless.getBeaconId()); + assertFalse("a tag nobody named was reported as named", nameless.isHasName()); + + assertEquals("the skipped device and its reason were dropped", + 1, fetched.getSkipped().size()); + } + + /** + * And the records it hands over are real plists. + * + *

This is the whole point of faking below {@code exporter.icloud} rather than above it. + * The candidates the double returns are the shipping dataclasses, so the XML Java parses here + * is produced by the same renderer a real account's would go through - which is the document + * the two sides actually have to agree about. + */ + @Test + public void therecordsAreRenderedByTheShippingRenderer() { + this.service.open().blockingAwait(); + this.service.fetch().blockingFirst(); + + final List records = + this.service.records(List.of("a-bike-tag")).blockingFirst(); + + assertEquals(1, records.size()); + assertTrue("the owned beacon plist is not a plist: " + + records.get(0).getOwnedBeaconPlist(), + records.get(0).getOwnedBeaconPlist().contains("The one operation here that writes to somebody's real Apple account, so what actually + * arrives matters more than what comes back. + */ + @Test + public void renamingSendsTheNameAndEmojiToTheAccount() { + this.service.open().blockingAwait(); + this.service.fetch().blockingFirst(); + + final AccessoryRecords bike = + this.service.records(List.of("a-bike-tag")).blockingFirst().get(0); + + this.service.rename("a-bike-tag", bike.getOwnedBeaconPlist(), + "Cargo bike", "🛻").blockingAwait(); + + final String sent = this.reached("renamedWith"); + assertTrue("the new name never reached the account: " + sent, sent.contains("Cargo bike")); + assertTrue("the new emoji never reached the account: " + sent, sent.contains("🛻")); + } + + /** Closing the service closes the client underneath it rather than leaking the session. */ + @Test + public void closingTheServiceClosesTheClient() { + this.service.open().blockingAwait(); + this.service.fetch().blockingFirst(); + + this.service.close(); + this.service = null; + + assertEquals("the Find My client was left open", "True", this.reached("closed")); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/BeaconIconTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/BeaconIconTest.java index 92a3f5d7..e2596fe6 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/BeaconIconTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/BeaconIconTest.java @@ -87,10 +87,74 @@ public void applesOwnHardwareStillGetsApplesLogo() { BeaconIcon.forBeacon(beacon(false, APPLE_VENDOR_ID))); } + /** + * An iPad is drawn as a tablet, not as a generic Find My accessory. + * + *

The decision used to be the vendor id alone, and that does not identify these at all: an + * AirTag's record carries 76 and an iPad's carries -1, so every iPhone, iPad and Mac on the + * account fell through to the third-party branch. An account read is mostly those, so the + * device list came back as a column of identical unknown tags - which is what @parawanderer + * saw the first time a real account was imported. + * + *

The model is the direct evidence: an accessory leaves it empty, a device fills it with + * an Apple model identifier. + */ + @Test + public void anappleDeviceIsDrawnAsWhatItIs() { + assertEquals(R.drawable.tablet_24px, BeaconIcon.forBeacon(withModel("iPad13,18"))); + assertEquals(R.drawable.laptop_24px, BeaconIcon.forBeacon(withModel("MacBookAir10,1"))); + assertEquals(R.drawable.smartphone_24px, BeaconIcon.forBeacon(withModel("iPhone15,2"))); + } + + /** And specifically not the accessory mark, which is the behaviour being replaced. */ + @Test + public void anappleDeviceIsNotDrawnAsAThirdPartyAccessory() { + for (final String model : new String[] {"iPad13,18", "MacBookAir10,1", "iPhone15,2"}) { + assertNotEquals("a " + model + " is one of the owner's own devices, not a tag" + + " somebody else made", + R.drawable.findmy_accessory, BeaconIcon.forBeacon(withModel(model))); + } + } + + /** + * Apple hardware this has no drawing of gets Apple's logo rather than a guess. + * + *

A Watch, a Vision Pro, something not shipped yet: "one of your Apple devices" is true, + * and drawing it as a third-party tag would not be. + */ + @Test + public void appleHardwareWithNoPictureOfItsOwnStillLooksApple() { + assertEquals(R.drawable.apple, BeaconIcon.forBeacon(withModel("Watch6,1"))); + } + + /** + * An accessory's empty model must not be mistaken for a device. + * + *

The one that keeps the change honest in the other direction: every AirTag has + * {@code model} empty, so a prefix check that treated empty as a match would draw the entire + * account as iPads. + */ + @Test + public void anaccessoryWithNoModelIsStillAnAccessory() { + assertEquals(R.drawable.findmy_accessory, BeaconIcon.forBeacon(withModel(""))); + assertEquals(R.drawable.findmy_accessory, BeaconIcon.forBeacon(withModel(null))); + } + + private static BeaconInformation withModel(final String model) { + return BeaconInformation.builder() + .beaconId("a-tag") + .originalName("Something") + .model(model) + // -1 is what a real device's record carries, and is the whole reason the vendor + // id cannot be what decides this. + .vendorId(-1) + .build(); + } + /** A Chipolo, a Pebblebee - findable, paired, and not made by Apple. */ @Test public void athirdPartyTagGetsTheFindableIcon() { - assertEquals(R.drawable.tag_third_party, + assertEquals(R.drawable.findmy_accessory, BeaconIcon.forBeacon(beacon(false, 0x009E))); } @@ -120,8 +184,8 @@ public void beingSelfGeneratedWinsOverAnyVendorId() { @Test public void thethreeIconsAreActuallyDifferent() { assertNotEquals(R.drawable.apple, R.drawable.tag_self_generated); - assertNotEquals(R.drawable.apple, R.drawable.tag_third_party); - assertNotEquals(R.drawable.tag_self_generated, R.drawable.tag_third_party); + assertNotEquals(R.drawable.apple, R.drawable.findmy_accessory); + assertNotEquals(R.drawable.tag_self_generated, R.drawable.findmy_accessory); } // ---------------------------------------------------------------- does it draw @@ -142,7 +206,7 @@ public void everyIconDrawsSomethingInBothThemes() { for (final int icon : new int[]{ R.drawable.apple, R.drawable.tag_self_generated, - R.drawable.tag_third_party}) { + R.drawable.findmy_accessory}) { final Drawable drawable = AppCompatResources.getDrawable(themed, icon); assertNotNull("icon " + icon + " did not load", drawable); @@ -170,7 +234,7 @@ public void thenewIconsLookDifferentFromApples() { final int haystack = paintedPixels( AppCompatResources.getDrawable(themed, R.drawable.tag_self_generated)); final int findable = paintedPixels( - AppCompatResources.getDrawable(themed, R.drawable.tag_third_party)); + AppCompatResources.getDrawable(themed, R.drawable.findmy_accessory)); assertNotEquals("the haystack draws the same coverage as Apple's logo", apple, haystack); assertNotEquals("the findable icon draws the same coverage as Apple's logo", @@ -197,7 +261,7 @@ public void renderTheIconsToLookAt() throws IOException { write("selfgenerated-" + variant, AppCompatResources.getDrawable(themed, R.drawable.tag_self_generated)); write("thirdparty-" + variant, - AppCompatResources.getDrawable(themed, R.drawable.tag_third_party)); + AppCompatResources.getDrawable(themed, R.drawable.findmy_accessory)); } } diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/ICloudStepsRenderTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/ICloudStepsRenderTest.java new file mode 100644 index 00000000..5b996511 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/ICloudStepsRenderTest.java @@ -0,0 +1,277 @@ +package dev.wander.android.opentagviewer.ui; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.view.View; + +import com.google.android.material.progressindicator.CircularProgressIndicator; + +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.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; + +import dev.wander.android.opentagviewer.Eventually; +import dev.wander.android.opentagviewer.db.AccountBeaconsForTests; +import dev.wander.android.opentagviewer.FetchFromICloudActivity; +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; +import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.icloud.FakeICloudService; +import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; + +/** + * Every step of the iCloud screen, drawn from the running activity. + * + *

Captured from the real screen rather than an inflated layout. The layout cannot be + * inflated on its own - {@code CircularProgressIndicator} refuses outside an activity - and this + * is the more honest picture anyway: it is what ships, with the real theme, the real inflater and + * the real Material widgets, rather than an approximation assembled for the test's convenience. + * + *

The pictures are for a person to look at; the spacing on these screens was set from them. + * What is asserted is the thing a picture cannot tell you: that the step actually has height. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class ICloudStepsRenderTest { + + private ActivityScenario scenario; + private KeychainMembershipRepository memberships; + + @Before + public void forgetAnyStoredMembership() { + this.memberships = new KeychainMembershipRepository( + UserAuthDataStore.getInstance(getInstrumentation().getTargetContext()), + new AppCryptographyUtil()); + this.memberships.forget().blockingAwait(); + // Finishing this flow writes real rows into the real database. Cleared here too, + // because a test that crashed left its tags behind for whatever runs next. + AccountBeaconsForTests.forgetThemAll(); + } + + @After + public void putTheRealOneBack() { + if (this.scenario != null) { + this.scenario.close(); + } + AppDependencies.reset(); + this.memberships.forget().blockingAwait(); + AccountBeaconsForTests.forgetThemAll(); + } + + private void open(final FakeICloudService fake) { + AppDependencies.replaceICloud(() -> fake); + this.scenario = ActivityScenario.launch(FetchFromICloudActivity.class); + } + + private boolean isShown(final int id) { + final boolean[] shown = {false}; + this.scenario.onActivity(a -> shown[0] = a.findViewById(id).getVisibility() == View.VISIBLE); + return shown[0]; + } + + @Test + public void thedeviceListAndPasscodeSteps() throws IOException { + final FakeICloudService fake = FakeICloudService.withTags(); + this.open(fake); + + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + this.capture("icloud_1_devices.png", R.id.icloud_device_container); + + Eventually.perform("a device", () -> isShown(R.id.icloud_passcode_container), + () -> onView(withText(containsString(FakeICloudService.AN_IPHONE.getSerial()))) + .perform(click())); + this.capture("icloud_2_passcode.png", R.id.icloud_passcode_container); + } + + @Test + public void theoverviewOfWhatWasFound() throws IOException { + final FakeICloudService fake = + FakeICloudService.withTags().alsoSkipping("My MacBook", "My iPhone"); + this.open(fake); + + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + Eventually.perform("a device", () -> isShown(R.id.icloud_passcode_container), + () -> onView(withText(containsString(FakeICloudService.AN_IPHONE.getSerial()))) + .perform(click())); + + onView(withId(R.id.icloud_passcode_input)).perform(replaceText("123456")); + Eventually.perform("unlock", () -> fake.timesCalled("fetch") > 0, + () -> onView(withId(R.id.icloud_primary_button)).perform(click())); + + Eventually.check(() -> onView(withId(R.id.icloud_results_container)) + .check(matches(isDisplayed()))); + this.capture("icloud_3_results.png", R.id.icloud_results_container); + } + + /** + * The spinner actually turns. + * + *

A screenshot cannot tell you this, which is the whole problem: a still frame of a + * rotating arc and a static icon are the same picture, and @parawanderer reasonably asked + * which one it was. So this draws it twice, a few frames apart, and insists the pixels + * changed. A spinner that had quietly stopped - or was never a spinner - fails here and + * nowhere else. + */ + /** + * It is a real progress indicator, not a picture of one. + * + *

What this cannot prove is that it turns, and the attempt is worth recording so + * nobody repeats it: drawing the view to a bitmap twice, a quarter-second apart, produced + * identical pixels even with the animator duration scale forced to 1. That is the headless + * managed device, not the app - a view drawn by hand off-screen does not tick its animator - + * and asserting on it would have been a test of this harness. + * + *

So this asserts the things that are true and checkable: the widget is Material's + * indeterminate {@code CircularProgressIndicator}, the same one the sign-in screen uses, and + * it is on screen while the account is being read. Whether it visibly spins is a question for + * a device with a window, and it is answered by looking. + */ + @Test + public void thewaitShowsARealProgressIndicator() { + this.open(FakeICloudService.withTags().takingItsTime(6000)); + + Eventually.check(() -> onView(withId(R.id.icloud_loading_container)) + .check(matches(isDisplayed()))); + + final boolean[] indeterminate = {false}; + final boolean[] shown = {false}; + this.scenario.onActivity(activity -> { + final CircularProgressIndicator spinner = activity.findViewById(R.id.icloud_spinner); + indeterminate[0] = spinner.isIndeterminate(); + shown[0] = spinner.isShown(); + }); + + assertTrue("the wait must show an indeterminate indicator, not a fixed one", + indeterminate[0]); + assertTrue("the indicator is not on screen while the account is being read", shown[0]); + } + + /** + * The wait sits in the middle of the screen, not just under the heading. + * + *

Every other step is content that starts below the title and grows downward. This one is + * a spinner and a line of text, and wrapped to its content it sat near the top with the rest + * of the screen empty underneath - which reads as a screen that failed to finish drawing + * rather than as one that is waiting. + * + *

Asserted as a position rather than looked at, because "near the top" is exactly the kind + * of thing a screenshot shows and nothing checks. The bar is deliberately loose: what is + * being pinned is that it is not hugging the heading, not a particular pixel. + */ + @Test + public void thewaitIsCentredRatherThanTuckedUnderTheHeading() { + this.open(FakeICloudService.withTags().takingItsTime(6000)); + + Eventually.check(() -> onView(withId(R.id.icloud_loading_container)) + .check(matches(isDisplayed()))); + + final int[] spinnerMiddle = {0}; + final int[] screenHeight = {0}; + this.scenario.onActivity(activity -> { + final View spinner = activity.findViewById(R.id.icloud_spinner); + final int[] where = new int[2]; + spinner.getLocationOnScreen(where); + + spinnerMiddle[0] = where[1] + spinner.getHeight() / 2; + screenHeight[0] = activity.getWindow().getDecorView().getHeight(); + }); + + final double downThePage = (double) spinnerMiddle[0] / screenHeight[0]; + + assertTrue("the spinner sits " + Math.round(downThePage * 100) + "% down the screen," + + " which is back up against the heading", + downThePage > 0.3 && downThePage < 0.7); + } + + /** The screen somebody actually looks at first, and the one that looked wrong. */ + @Test + public void thewaitingScreen() throws IOException { + this.open(FakeICloudService.withTags().takingItsTime(4000)); + + Eventually.check(() -> onView(withId(R.id.icloud_loading_container)) + .check(matches(isDisplayed()))); + this.capture("icloud_0_waiting.png", R.id.icloud_loading_container); + } + + @Test + public void thenoTagsScreen() throws IOException { + this.open(FakeICloudService.withNothingToRecoverFrom()); + + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(isDisplayed()))); + this.capture("icloud_4_no_tags.png", R.id.icloud_no_tags_container); + } + + @Test + public void theserviceHavingABadDayScreen() throws IOException { + this.open(FakeICloudService.whereTheServiceIsUnsure()); + + Eventually.check(() -> onView(withId(R.id.icloud_retry_container)) + .check(matches(isDisplayed()))); + this.capture("icloud_5_retry.png", R.id.icloud_retry_container); + } + + /** + * Draw the whole window, and insist the step in it has height. + * + *

The window rather than the step alone, because the spacing being judged is partly the + * room above and below it - a step cropped to its own bounds hides exactly that. + */ + private void capture(final String name, final int stepId) throws IOException { + assertTrue(name + ": the step measured to nothing", heightOf(stepId) > 0); + + final Bitmap[] shot = new Bitmap[1]; + this.scenario.onActivity(activity -> { + final View decor = activity.getWindow().getDecorView(); + final Bitmap bitmap = Bitmap.createBitmap( + Math.max(decor.getWidth(), 1), Math.max(decor.getHeight(), 1), + Bitmap.Config.ARGB_8888); + decor.draw(new Canvas(bitmap)); + shot[0] = bitmap; + }); + + write(shot[0], name); + } + + private int heightOf(final int stepId) { + final int[] height = {0}; + this.scenario.onActivity(a -> height[0] = a.findViewById(stepId).getHeight()); + return height[0]; + } + + private static void write(final Bitmap bitmap, final String name) throws IOException { + final String directory = androidx.test.platform.app.InstrumentationRegistry + .getArguments().getString("additionalTestOutputDir"); + if (directory == null || bitmap == null) { + return; + } + + try (FileOutputStream out = new FileOutputStream(new File(directory, name))) { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); + } + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/SpinnersLookLikeSpinnersTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/SpinnersLookLikeSpinnersTest.java new file mode 100644 index 00000000..32d5be16 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/SpinnersLookLikeSpinnersTest.java @@ -0,0 +1,141 @@ +package dev.wander.android.opentagviewer.ui; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.graphics.Color; +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 com.google.android.material.progressindicator.BaseProgressIndicator; +import com.google.android.material.progressindicator.CircularProgressIndicator; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; + +import dev.wander.android.opentagviewer.R; + +/** + * Every circular spinner in the app is configured to look like one. + * + *

Left bare, Material's indeterminate circular indicator is a thin arc with square ends and + * nothing behind it, which at 18-24dp does not read as something turning - it reads as a + * stray refresh glyph somebody forgot to remove. Four properties fix that: a track behind the + * arc, rounded ends, and inward show/hide so it grows out of nothing instead of appearing. + * + *

This exists because the sign-in screen got all four and the Anisette status line beside it + * did not, and the difference is invisible in a diff and obvious on a device - @parawanderer + * spotted it in a slow-motion run of a test that was passing. One screen having a spinner and + * its neighbour having a glyph is the kind of thing that is noticed without being nameable. + * + *

Found by walking every layout rather than by listing the ones known about, so a + * spinner added later is covered by having been added. The count is asserted too: if inflation + * started failing wholesale this would find nothing and pass, which is the shape of a test that + * has quietly stopped testing. + */ +@RunWith(AndroidJUnit4.class) +public class SpinnersLookLikeSpinnersTest { + + /** What the app has today. A new spinner should raise this, not be excluded from it. */ + private static final int AT_LEAST_THIS_MANY = 8; + + private static Context themed() { + return new ContextThemeWrapper( + getInstrumentation().getTargetContext(), R.style.Theme_OpenTagViewer); + } + + /** Every layout the app ships, by reflection over the generated R class. */ + private static List everyLayout() { + final List layouts = new ArrayList<>(); + + for (final Field field : R.layout.class.getFields()) { + try { + layouts.add(field.getInt(null)); + } catch (final IllegalAccessException ignored) { + // Not a layout id we can read; nothing to inflate. + } + } + + return layouts; + } + + private static void collectSpinners(final View view, + final List into) { + if (view instanceof CircularProgressIndicator) { + into.add((CircularProgressIndicator) view); + } + if (view instanceof ViewGroup) { + final ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + collectSpinners(group.getChildAt(i), into); + } + } + } + + private static List everySpinnerInTheApp() { + final Context context = themed(); + final LayoutInflater inflater = LayoutInflater.from(context); + final List found = new ArrayList<>(); + + getInstrumentation().runOnMainSync(() -> { + for (final int layout : everyLayout()) { + try { + collectSpinners(inflater.inflate(layout, null, false), found); + } catch (final Exception | Error ignored) { + // Some layouts need an activity, a binding, or Play Services. Skipping one + // is only safe because the count below would notice if skipping became the + // rule rather than the exception. + } + } + }); + + return found; + } + + @Test + public void everySpinnerHasATrackBehindIt() { + final List spinners = everySpinnerInTheApp(); + + assertTrue("only " + spinners.size() + " spinner(s) were found; inflation is failing and" + + " this test is no longer checking anything", spinners.size() >= AT_LEAST_THIS_MANY); + + for (final CircularProgressIndicator spinner : spinners) { + assertTrue("a spinner has no track behind its arc, so it reads as a stray glyph" + + " rather than as something turning", + spinner.getTrackColor() != Color.TRANSPARENT); + } + } + + @Test + public void everySpinnerHasRoundedEnds() { + for (final CircularProgressIndicator spinner : everySpinnerInTheApp()) { + assertTrue("a spinner still has square ends", spinner.getTrackCornerRadius() > 0); + } + } + + /** + * Inward, so it grows out of nothing rather than appearing mid-turn. + * + *

The one that is easiest to leave off and hardest to argue about afterwards: without it + * the spinner pops into existence at full size, which on a screen that is already changing + * reads as a flicker. + */ + @Test + public void everySpinnerGrowsAndShrinksRatherThanAppearing() { + for (final CircularProgressIndicator spinner : everySpinnerInTheApp()) { + assertTrue("a spinner appears rather than growing in", + spinner.getShowAnimationBehavior() == BaseProgressIndicator.SHOW_INWARD); + assertTrue("a spinner vanishes rather than shrinking away", + spinner.getHideAnimationBehavior() == BaseProgressIndicator.HIDE_INWARD); + } + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/ThemedFindMyIconTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/ThemedFindMyIconTest.java new file mode 100644 index 00000000..6f86f661 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/ThemedFindMyIconTest.java @@ -0,0 +1,365 @@ +package dev.wander.android.opentagviewer.ui; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.util.TypedValue; +import android.widget.ImageView; + +import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.data.model.BeaconInformation; + +/** + * The Find My accessory mark, which is the first icon here that colours itself. + * + *

Every other icon in this app is a single-colour path flattened to whatever the screen wants. + * This one keeps Apple's blue cone - the part that makes it read as "a findable thing" at 24dp - + * and takes its surround from the theme, so it sits on the row instead of on top of it. + * + *

Three ways that goes wrong silently, and one test each: + * + *

+ */ +@RunWith(AndroidJUnit4.class) +public class ThemedFindMyIconTest { + + /** Rendered at twice the 48-unit viewport, so one viewport unit is two pixels. */ + private static final int PIXELS_PER_UNIT = 2; + + /** The group transform in the drawable. Features sit this much further out than their path. */ + private static final float ARTWORK_SCALE = 1.4f; + + /** How far down from the centre, in pixels, a feature at this viewport radius lands. */ + private static int at(final double viewportRadius) { + return (int) Math.round(viewportRadius * ARTWORK_SCALE * PIXELS_PER_UNIT); + } + + /** Anything that is not Apple and not self-generated gets the Find My mark. */ + private static BeaconInformation aThirdPartyTag() { + return BeaconInformation.builder() + .beaconId("chipolo") + .originalName("Keys") + .vendorId(0x08C3) + .build(); + } + + private static BeaconInformation anAppleTag() { + return BeaconInformation.builder() + .beaconId("airtag") + .originalName("Wallet") + .vendorId(76) + .build(); + } + + private static Context themed(final boolean night) { + final Context base = getInstrumentation().getTargetContext(); + + final Configuration configuration = new Configuration(base.getResources().getConfiguration()); + configuration.uiMode = (configuration.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) + | (night ? Configuration.UI_MODE_NIGHT_YES : Configuration.UI_MODE_NIGHT_NO); + + return new ContextThemeWrapper( + base.createConfigurationContext(configuration), R.style.Theme_OpenTagViewer); + } + + private static Bitmap drawn(final Context context, final BeaconInformation beacon) { + final ImageView view = new ImageView(context); + getInstrumentation().runOnMainSync(() -> BeaconIcon.applyTo(view, beacon)); + + final Drawable icon = view.getDrawable(); + final Bitmap bitmap = Bitmap.createBitmap(96, 96, Bitmap.Config.ARGB_8888); + icon.setBounds(0, 0, 96, 96); + + // Through the view's own tint, so what is measured is what the screen paints - a version + // that drew the raw drawable would pass whatever the surfaces did to it. + if (view.getImageTintList() != null) { + icon.setTint(view.getImageTintList().getDefaultColor()); + } + icon.draw(new Canvas(bitmap)); + + return bitmap; + } + + private static Set coloursIn(final Bitmap bitmap) { + final Set found = new HashSet<>(); + for (int x = 0; x < bitmap.getWidth(); x += 2) { + for (int y = 0; y < bitmap.getHeight(); y += 2) { + final int pixel = bitmap.getPixel(x, y); + if (Color.alpha(pixel) > 200) { + found.add(pixel); + } + } + } + return found; + } + + private static double luminance(final int colour) { + final double[] channel = new double[3]; + final int[] raw = {Color.red(colour), Color.green(colour), Color.blue(colour)}; + + for (int i = 0; i < 3; i++) { + final double v = raw[i] / 255d; + channel[i] = v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + } + + return 0.2126 * channel[0] + 0.7152 * channel[1] + 0.0722 * channel[2]; + } + + private static double contrast(final int a, final int b) { + final double first = luminance(a); + final double second = luminance(b); + + return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05); + } + + private static int resolved(final Context context, final int attribute) { + final TypedValue value = new TypedValue(); + context.getTheme().resolveAttribute(attribute, value, true); + return value.data; + } + + /** It draws something. A themed attribute with no theme draws nothing at all. */ + @Test + public void themarkIsNotBlank() { + for (final boolean night : new boolean[] {false, true}) { + final Set colours = coloursIn(drawn(themed(night), aThirdPartyTag())); + + assertTrue("the Find My mark drew nothing in " + (night ? "dark" : "light") + + " mode - a theme attribute with no theme paints nothing", + colours.size() > 1); + } + } + + /** + * It is still more than one colour once the screen has had it. + * + *

The failure this catches is a tint left on the view: it is applied to every path at + * once, so the cone, the ring and the surround all become {@code colorOutline} and the icon + * becomes a circle. Nothing throws, and it looks like a deliberate design. + */ + @Test + public void thetintDoesNotFlattenItIntoABlob() { + for (final boolean night : new boolean[] {false, true}) { + final Set colours = coloursIn(drawn(themed(night), aThirdPartyTag())); + + assertTrue("the Find My mark came out in " + colours.size() + " colour(s) in " + + (night ? "dark" : "light") + " mode; a tint has flattened it", + colours.size() >= 4); + } + } + + /** The blue that makes it recognisable survives both themes untouched. */ + @Test + public void thecomeIsStillApplesBlue() { + for (final boolean night : new boolean[] {false, true}) { + assertTrue("the cone's blue was recoloured in " + (night ? "dark" : "light") + " mode", + coloursIn(drawn(themed(night), aThirdPartyTag())) + .contains(Color.parseColor("#2979ff"))); + } + } + + /** + * The silhouette carries against the row it sits on, in both modes. + * + *

3:1 is the WCAG floor for something that is not text. + * + *

Measured off the drawn pixel, not off {@code colorOutline}. It used to resolve + * that attribute and compare it to {@code colorSurface} - which is a true statement about + * Material's palette and says nothing about this icon, the same flaw + * {@link #theringsInsideItKeepApplesTonalOrdering} documents. It could not see the + * translucent step the disc is softened with, so it went on passing while the surround was + * lightened past the floor: at {@code fillAlpha="0.25"} it stayed green, and that is exactly + * the change it exists to stop. + * + *

Sampled straight down from the centre, where the cone does not reach - the same place + * the ladder test reads the surround. + */ + @Test + public void thesilhouetteClearsThreeToOneAgainstTheRow() { + for (final boolean night : new boolean[] {false, true}) { + final Context context = themed(night); + + final int silhouette = drawn(context, aThirdPartyTag()).getPixel(48, 48 + at(13)); + final int row = resolved(context, com.google.android.material.R.attr.colorSurface); + final double ratio = contrast(silhouette, row); + + assertTrue("the Find My mark's disc sits at " + String.format("%.2f", ratio) + ":1" + + " against the row in " + (night ? "dark" : "light") + " mode", + ratio >= 3.0); + } + } + + /** + * The tonal ladder inside the disc, measured off the pixels rather than off the theme. + * + *

The first version of this resolved three theme attributes and compared those, which + * asserted something true about Material's palette and nothing at all about the icon. It + * passed happily while the ordering was inverted and the disc had gone dark-to-white inward. + * + *

Sampled straight down from the centre, which is the one direction the cone does not + * cross. The ordering is Apple's: the ring is the lightest, the inner disc sits between it + * and the surround, and the surround is the darkest. + */ + @Test + public void theringsInsideItKeepApplesTonalOrdering() { + for (final boolean night : new boolean[] {false, true}) { + final Bitmap drawn = drawn(themed(night), aThirdPartyTag()); + final String mode = night ? "dark" : "light"; + + final int inner = drawn.getPixel(48, 48 + at(8)); + final int ring = drawn.getPixel(48, 48 + at(10.5)); + final int surround = drawn.getPixel(48, 48 + at(13)); + + assertTrue("the ring is not lighter than the inner disc in " + mode + " mode", + luminance(ring) > luminance(inner)); + assertTrue("the inner disc is not lighter than the surround in " + mode + " mode", + luminance(inner) > luminance(surround)); + assertTrue("the ring and the surround are the same colour in " + mode + " mode", + contrast(ring, surround) > 1.2); + } + } + + /** + * The white ring around the centre dot is still visible against what it sits on. + * + *

This is what the first attempt broke. Mapping the inner disc to + * {@code colorSurfaceVariant} made it near-white in light mode, and a near-white ring on a + * near-white disc is not a subtle ring - it is a missing one. + * + *

The bar is 1.5:1 rather than 3:1 on purpose: Apple's own green sits at about 1.7:1 + * here, so demanding more would mean failing the icon this one is modelled on. What is being + * caught is the ring disappearing, not the ring being quiet. + */ + @Test + public void thewhiteRingIsStillVisibleAgainstTheInnerDisc() { + for (final boolean night : new boolean[] {false, true}) { + final Bitmap drawn = drawn(themed(night), aThirdPartyTag()); + + final int ring = drawn.getPixel(48, 48 + at(3.5)); + final int inner = drawn.getPixel(48, 48 + at(8)); + final double ratio = contrast(ring, inner); + + assertTrue("the white ring sits at " + String.format("%.2f", ratio) + ":1 against the" + + " inner disc in " + (night ? "dark" : "light") + " mode", + ratio > 1.5); + } + } + + /** + * The recycling case. + * + *

The device list is a RecyclerView, so a row that showed a Find My accessory is handed + * straight to the next tag. A version of this that only cleared the tint would leave that row + * painting an Apple logo with none - and it would only happen to whichever rows were + * recycled, which reads as a scrolling bug rather than as a missing else. + */ + @Test + public void arecycledRowGetsItsTintBack() { + final Context context = themed(false); + final ImageView view = new ImageView(context); + + getInstrumentation().runOnMainSync(() -> { + BeaconIcon.applyTo(view, aThirdPartyTag()); + assertNull("the Find My mark must not be tinted", view.getImageTintList()); + + BeaconIcon.applyTo(view, anAppleTag()); + }); + + assertNotNull("a recycled row must get its tint back for a flat icon", + view.getImageTintList()); + } + + /** + * It fills its box like the other icons do. + * + *

The artwork runs from 8 to 40 in a 48-unit viewport, so drawn as-authored it covers two + * thirds of its width while {@code apple.xml} covers about ninety per cent of its own. Beside + * each other in one list that does not read as two different icons - it reads as the same + * icon at the wrong size, which is a thing people notice and cannot name. + * + *

Measured against the Apple mark rather than against a number, because the number that + * matters is the comparison: whatever either icon is changed to, they have to keep looking + * like they belong to one set. + */ + @Test + public void itfillsItsBoxAsMuchAsTheAppleMarkDoes() { + final Context context = themed(false); + + final double findMy = coveredFractionOf(drawn(context, aThirdPartyTag())); + final double apple = coveredFractionOf(drawn(context, anAppleTag())); + + assertTrue("the Find My mark covers " + Math.round(findMy * 100) + "% of its box against" + + " the Apple mark's " + Math.round(apple * 100) + "%; it will read as" + + " the same icon at the wrong size", + findMy >= apple * 0.8); + } + + /** How much of the box has something opaque drawn on it. */ + private static double coveredFractionOf(final Bitmap bitmap) { + int covered = 0; + int total = 0; + + for (int x = 0; x < bitmap.getWidth(); x++) { + for (int y = 0; y < bitmap.getHeight(); y++) { + total++; + if (Color.alpha(bitmap.getPixel(x, y)) > 200) { + covered++; + } + } + } + + return (double) covered / total; + } + + /** Both modes, for a person to look at. Not an assertion - everything above is. */ + @Test + public void picturesOfIt() throws IOException { + write(drawn(themed(false), aThirdPartyTag()), "findmy_icon-light.png"); + write(drawn(themed(true), aThirdPartyTag()), "findmy_icon-dark.png"); + write(drawn(themed(false), anAppleTag()), "apple_icon-light.png"); + write(drawn(themed(true), anAppleTag()), "apple_icon-dark.png"); + } + + private static void write(final Bitmap bitmap, final String name) throws IOException { + final String directory = androidx.test.platform.app.InstrumentationRegistry + .getArguments().getString("additionalTestOutputDir"); + if (directory == null) { + return; + } + + try (FileOutputStream out = new FileOutputStream(new File(directory, name))) { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); + } + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/FakeMapProvider.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/FakeMapProvider.java index 0f42c86b..1f8dbaee 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/FakeMapProvider.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/FakeMapProvider.java @@ -100,9 +100,25 @@ public void setMapStyle(final MapStyle mapStyle) { this.style = mapStyle; } + /** + * Hands back the id it was given, as both real providers do. + * + *

This used to invent {@code "marker-0"}, {@code "marker-1"} and so on, which reads like a + * reasonable thing for a fake to do and is wrong: {@code GoogleMapProvider} and + * {@code AMapProvider} both {@code return marker.getId()} and key their own maps by it, and + * {@code MapsActivity} relies on that - it calls {@code removeMarker(beaconId)} to replace a + * tag's pin. + * + *

So against the fake that removal matched nothing and markers quietly accumulated. Any + * test of redrawing would have measured the fake's divergence rather than the app, which is + * the specific way a fake stops being useful. + */ @Override public String addMarker(final MapMarker marker) { - final String id = "marker-" + (this.nextId++); + final String id = marker.getId() != null + ? marker.getId() + : "unidentified-" + (this.nextId++); + this.markers.put(id, new PlacedMarker(id, marker)); return id; } diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/MarkerFollowsTheThemeTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/MarkerFollowsTheThemeTest.java new file mode 100644 index 00000000..5d21aa30 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/MarkerFollowsTheThemeTest.java @@ -0,0 +1,310 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.util.TypedValue; +import android.view.ContextThemeWrapper; + +import androidx.core.graphics.ColorUtils; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.R; + +/** + * The colour a map pin is filled with. + * + *

The pins and the cards drifted apart on exactly the setting meant to make them match. + * A pin was filled from {@code R.color.md_theme_background} while the tag card tinted itself with + * {@code ?android:attr/colorBackground}. Those are the same value in every built-in theme, night + * included - and stop being the same the moment system colours are on, because + * {@code DynamicColors} rewrites the theme attribute and cannot rewrite a fixed value in + * colors.xml. So the cards took the wallpaper's tint and the pins stayed on the app's palette. + * + *

The real risk in fixing it is contrast, not colour. Once the fill can be anything a + * wallpaper suggests, an icon drawn in a fixed grey can land on top of something almost exactly + * its own shade. A pin here once measured 1.23:1 - present, correct, and invisible - which is why + * the ratio is asserted rather than eyeballed, and why the icon colour moved to one Material + * guarantees against the surfaces around it. + */ +@RunWith(AndroidJUnit4.class) +public class MarkerFollowsTheThemeTest { + + /** WCAG for non-text. A pin is a graphical object, so 3:1 rather than 4.5:1. */ + private static final double MINIMUM_CONTRAST = 3.0d; + + private Context themed(final boolean night) { + final Context base = getInstrumentation().getTargetContext(); + + final Configuration configuration = new Configuration(base.getResources().getConfiguration()); + configuration.uiMode = (configuration.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) + | (night ? Configuration.UI_MODE_NIGHT_YES : Configuration.UI_MODE_NIGHT_NO); + + return new ContextThemeWrapper( + base.createConfigurationContext(configuration), R.style.Theme_OpenTagViewer); + } + + private int cardBackgroundOf(final Context context) { + final TypedValue found = new TypedValue(); + context.getTheme().resolveAttribute(android.R.attr.colorBackground, found, true); + return found.data; + } + + /** + * The fill is whatever the card asked for. + * + *

Asserted against the resolved attribute rather than a named colour, so it keeps holding + * when a theme changes what that attribute points at - which is the entire scenario. + */ + @Test + public void thepinIsFilledWithTheCardsOwnBackground() { + for (final boolean night : new boolean[] {false, true}) { + final Context context = this.themed(night); + + assertEquals("the pin fill stopped following the card background, night=" + night, + this.cardBackgroundOf(context), MarkerPalette.fill(context)); + } + } + + /** + * It is read from the theme, not from the colour resource. + * + *

The regression itself. Reading the resource passes every assertion above in the built-in + * themes, which is why it went unnoticed - so this drives the case that told them apart: a + * theme whose {@code colorBackground} has deliberately been overridden, standing in for what + * DynamicColors does at runtime. + */ + @Test + public void arethemedBackgroundIsPreferredOverTheStaticResource() { + final Context recoloured = new ContextThemeWrapper( + getInstrumentation().getTargetContext(), R.style.Theme_OpenTagViewer); + recoloured.getTheme().applyStyle(R.style.TestOnly_RecolouredBackground, true); + + final int staticResource = + getInstrumentation().getTargetContext().getColor(R.color.md_theme_background); + + assertNotEquals("the test theme did not actually change anything, so this proves nothing", + staticResource, this.cardBackgroundOf(recoloured)); + + assertEquals("the pin is still reading the colour resource, so a themed background " + + "leaves it behind", + this.cardBackgroundOf(recoloured), MarkerPalette.fill(recoloured)); + } + + /** + * And the icon stays visible on it. + * + *

3:1 against the fill, in both modes. Without this the change could quietly make a pin + * whose icon is the same shade as the pin. + */ + @Test + public void theiconOnApinIsLegibleAgainstIt() { + for (final boolean night : new boolean[] {false, true}) { + final Context context = this.themed(night); + + final double ratio = ColorUtils.calculateContrast( + MarkerPalette.icon(context), MarkerPalette.fill(context)); + + assertTrue(String.format( + "the pin icon is %.2f:1 against its own fill (night=%s), below the " + + "%.1f:1 a graphical object needs", + ratio, night, MINIMUM_CONTRAST), + ratio >= MINIMUM_CONTRAST); + } + } + + /** + * The colour reaches the pixels. + * + *

Everything above is about numbers agreeing. This renders the pin the way the map does + * and reads the middle of its head, because a fill that is computed correctly and then not + * painted looks exactly like one that was never computed. + */ + @Test + public void thefillIsWhatActuallyGetsDrawn() { + final Context context = this.themed(false); + final int fill = MarkerPalette.fill(context); + + final Bitmap pin = VectorImageGeneratorUtil.makeMarker( + context.getResources(), R.drawable.apple, fill, MarkerPalette.icon(context)); + + // Just inside the head of the pin and clear of the icon: a quarter down, a quarter across. + final int sampled = pin.getPixel(pin.getWidth() / 4, pin.getHeight() / 4); + + assertEquals("the pin's red channel is not the fill's", + Color.red(fill), Color.red(sampled), 8); + assertEquals("the pin's green channel is not the fill's", + Color.green(fill), Color.green(sampled), 8); + assertEquals("the pin's blue channel is not the fill's", + Color.blue(fill), Color.blue(sampled), 8); + } + + /** + * An emoji sits where the icon sits. + * + *

Spotted by @parawanderer in the render: the bike was low and left, next to an apple that + * was not. The two are drawn by different means - {@code drawText} against {@code setBounds} - + * and the emoji was placed by halving the whole drawable's height, which is below the middle + * of a pin's head because a pin is a circle with a point hanging off it. + * + *

Measured rather than looked at, by finding what each pin actually painted: the bounding + * box of every pixel that is not the fill, whose centre is where the thing on the pin really + * is. Comparing the two against each other rather than against a constant means this keeps + * holding if the pin drawable itself ever changes shape. + */ + @Test + public void anemojiIsCentredWhereTheIconIs() { + final Context context = this.themed(false); + final int fill = MarkerPalette.fill(context); + + final int[] icon = paintedCentreOf(VectorImageGeneratorUtil.makeMarker( + context.getResources(), R.drawable.apple, fill, MarkerPalette.icon(context)), fill); + final int[] emoji = paintedCentreOf(VectorImageGeneratorUtil.makeMarker( + context.getResources(), "🚲", fill), fill); + + assertEquals("the emoji is " + Math.abs(icon[0] - emoji[0]) + + "px off the icon horizontally", + icon[0], emoji[0], 4); + assertEquals("the emoji is " + Math.abs(icon[1] - emoji[1]) + + "px off the icon vertically", + icon[1], emoji[1], 4); + } + + /** The centre of everything drawn on the pin that is not the pin itself. */ + private static int[] paintedCentreOf(final Bitmap pin, final int fill) { + int minX = pin.getWidth(); + int minY = pin.getHeight(); + int maxX = -1; + int maxY = -1; + + // Inset, so the pin's own outline and shadow are not mistaken for its contents. + final int inset = pin.getWidth() / 4; + + for (int x = inset; x < pin.getWidth() - inset; x++) { + for (int y = inset; y < pin.getHeight() - inset; y++) { + final int pixel = pin.getPixel(x, y); + + if (Math.abs(Color.red(pixel) - Color.red(fill)) < 24 + && Math.abs(Color.green(pixel) - Color.green(fill)) < 24 + && Math.abs(Color.blue(pixel) - Color.blue(fill)) < 24) { + continue; + } + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + + assertTrue("nothing was drawn on the pin at all", maxX >= 0); + + return new int[] {(minX + maxX) / 2, (minY + maxY) / 2}; + } + + /** + * Render the pins beside the background they are meant to match, so somebody can look. + * + *

Not an assertion - everything above is. This exists so that when one of those + * fails, the picture says what "wrong" looked like. Each panel is a pin drawn on a block of + * the very colour its fill is taken from, so a pin that has stopped following the theme shows + * up as the one square with something visible in the middle of it. + */ + @Test + public void renderThePinsForSomebodyToLookAt() { + this.render("map_marker-light", this.themed(false)); + this.render("map_marker-dark", this.themed(true)); + + final Context recoloured = new ContextThemeWrapper( + getInstrumentation().getTargetContext(), R.style.Theme_OpenTagViewer); + recoloured.getTheme().applyStyle(R.style.TestOnly_RecolouredBackground, true); + this.render("map_marker-recoloured", recoloured); + } + + private void render(final String name, final Context context) { + final int fill = MarkerPalette.fill(context); + + final Bitmap withIcon = VectorImageGeneratorUtil.makeMarker( + context.getResources(), R.drawable.apple, fill, MarkerPalette.icon(context)); + final Bitmap withEmoji = VectorImageGeneratorUtil.makeMarker( + context.getResources(), "🚲", fill); + + final int pad = 16; + final Bitmap sheet = Bitmap.createBitmap( + withIcon.getWidth() + withEmoji.getWidth() + pad * 3, + Math.max(withIcon.getHeight(), withEmoji.getHeight()) + pad * 2, + Bitmap.Config.ARGB_8888); + + final Canvas canvas = new Canvas(sheet); + // The card's colour behind them, which is the whole claim being made. + canvas.drawColor(fill); + canvas.drawBitmap(withIcon, pad, pad, null); + canvas.drawBitmap(withEmoji, pad * 2f + withIcon.getWidth(), pad, null); + + write(name + ".png", sheet); + } + + private static void write(final String fileName, final Bitmap bitmap) { + final String fromAgp = InstrumentationRegistry.getArguments() + .getString("additionalTestOutputDir"); + + final File dir = fromAgp != null + ? new File(fromAgp) + : getInstrumentation().getTargetContext().getExternalFilesDir("marker-shots"); + + if (dir == null) { + return; + } + //noinspection ResultOfMethodCallIgnored + dir.mkdirs(); + + final File target = new File(dir, fileName); + try (FileOutputStream out = new FileOutputStream(target)) { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); + } catch (IOException problem) { + // Fail rather than log: a run that quietly produced no images looks like a pass. + throw new AssertionError("could not write " + target, problem); + } + } + + /** + * Two themes produce two different pins. + * + *

Guards the bitmap cache, which is keyed on the colour. A cache keyed on anything less + * would hand the second theme the first theme's pin, and the symptom would be identical to + * the bug this all fixes. + */ + @Test + public void twothemesDoNotShareOnePin() { + final Context light = this.themed(false); + final Context dark = this.themed(true); + + final Bitmap onLight = VectorImageGeneratorUtil.makeMarker( + light.getResources(), "🚲", MarkerPalette.fill(light)); + final Bitmap onDark = VectorImageGeneratorUtil.makeMarker( + dark.getResources(), "🚲", MarkerPalette.fill(dark)); + + assertNotEquals("the light and dark themes resolved to the same fill, so this proves " + + "nothing about the cache", + MarkerPalette.fill(light), MarkerPalette.fill(dark)); + + assertNotEquals("both themes were handed the same cached pin", + onLight.getPixel(onLight.getWidth() / 4, onLight.getHeight() / 4), + onDark.getPixel(onDark.getWidth() / 4, onDark.getHeight() / 4)); + } +} 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 new file mode 100644 index 00000000..dd2d9136 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TheMapDrawsWhatIsStoredTest.java @@ -0,0 +1,386 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.content.Intent; + +import com.chaquo.python.PyObject; +import com.chaquo.python.Python; +import com.chaquo.python.android.AndroidPlatform; + +import androidx.lifecycle.Lifecycle; +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.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Collectors; + +import dev.wander.android.opentagviewer.DeviceStateGuard; +import dev.wander.android.opentagviewer.Eventually; +import dev.wander.android.opentagviewer.MapsActivity; +import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; +import dev.wander.android.opentagviewer.db.repo.UserAuthRepository; +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.Import; +import dev.wander.android.opentagviewer.db.room.entity.LocationReport; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; +import dev.wander.android.opentagviewer.util.rx.RefreshPolicy; + +/** + * The map screen, driven for the first time. + * + *

{@code MapsActivity} had never been started by a test. The instrumented suite runs on + * the {@code aosp-atd} managed device, which carries no Play Services, so a real map cannot + * initialise - and the map, the tag carousel and everything hanging off them went uncovered. A + * change to any of it could compile, pass the whole suite, and crash on launch. + * + *

{@link FakeMapProvider} has existed for a while and was only ever exercised directly, which + * proves the fake works and nothing about the screen. This is the other half: the activity is + * launched for real, against a database with tags in it, and asked what it drew. + * + *

Drawing anything needs a session that restores, which is why {@code apple_test_double} + * exists. {@code handleAuthAndShowDevices} zips the cached-beacon stream with + * {@code PythonAuthService.restoreAccount}, so a session that will not restore disposes the + * drawing side before it emits - and no session a test can write by hand will deserialise into a + * FindMy.py account. Confirmed rather than assumed: taking the double out again turns all three + * marker tests red. + * + *

Restoring needs no network - {@code getAccount} is {@code AppleAccount.from_json} and + * nothing else - so the double replaces only that and the fetch. Everything between them is the + * shipping code. + * + *

With the fetch reachable too, the chain runs end to end: the session restores, the fetch + * crosses the bridge, Python serialises what the account returned, the repository stores it, and + * the screen redraws the tag where it now is. Which also makes redrawing testable - the cached + * pin is drawn and then replaced - so "one marker, not two" is asserted rather than deferred. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class TheMapDrawsWhatIsStoredTest { + + private static final String A_TEST_USER = "map-draws@example.com"; + + private static final String FOUND_TAG = "a-located-tag"; + private static final String NEVER_SEEN_TAG = "a-tag-nobody-walked-past"; + + private static final double LATITUDE = 52.370216; + private static final double LONGITUDE = 4.895168; + + /** Somewhere a fetch could not be confused with the cached report. */ + private static final double FETCHED_LATITUDE = 48.858370; + private static final double FETCHED_LONGITUDE = 2.294481; + + private static final String A_PLIST = "" + + "" + + "batteryLevel1" + + "model" + + "pairingDate2025-02-27T20:03:32Z" + + "privateKeykey" + + "databm90LWEtcmVhbC1rZXk=" + + "productId21760" + + "stableIdentifier2001~#0~#A0" + + "systemVersion2.0.73" + + "vendorId76" + + ""; + + private OpenTagViewerDatabase db; + private DeviceStateGuard guard; + private FakeMapProvider fake; + private ActivityScenario scenario; + private PyObject appleDouble; + + @Before + public void seedTwoTagsAndSubstituteTheMap() { + final Context context = getInstrumentation().getTargetContext(); + + this.guard = DeviceStateGuard.capture(context); + this.db = OpenTagViewerDatabase.getInstance(context); + this.forgetThem(); + + // **Enough of a session to get past the door, and no more.** The screen sends anybody + // without one straight to the login screen, and everything below happens after that + // check. Restoring this into a real Apple account fails, on a background thread, which + // is exactly the state somebody with an expired session is in. + new UserAuthRepository(UserAuthDataStore.getInstance(context), new AppCryptographyUtil()) + .storeUserAuth("{\"not\":\"a restorable account\"}" + .getBytes(StandardCharsets.UTF_8)) + .blockingAwait(); + + final long importId = this.db.importDao().insert(Import.builder() + .version("0.0.2").importedAt(1_700_000_000_000L).exportedAt(1_699_000_000_000L) + .sourceUser(A_TEST_USER).exportedVia("OpenTagViewer.wizard:test").build()); + + this.insert(importId, FOUND_TAG, "Bike"); + this.insert(importId, NEVER_SEEN_TAG, "Wallet"); + + this.db.locationReportDao().insertAll(LocationReport.builder() + .hashId("a-stored-report") + .beaconId(FOUND_TAG) + .publishedAt(1_700_000_000_000L) + .description("Wi-Fi") + .timestamp(1_700_000_000_000L) + .confidence(0) + .latitude(LATITUDE) + .longitude(LONGITUDE) + .horizontalAccuracy(83) + .status(144) + .lastUpdate(1_700_000_000_000L) + .build()); + + // **Otherwise the startup fetch is skipped.** RefreshPolicy is a process-wide singleton + // that survives an activity being rebuilt - deliberately, so a theme change does not cost + // a full walk of every tag's key history - which also means one test's fetch suppresses + // the next test's. + RefreshPolicy.resetShared(); + + this.fake = new FakeMapProvider(); + MapProviderFactory.replaceWith(() -> this.fake); + } + + /** + * Make the stored session restore, so the drawing side of the zip actually runs. + * + *

Not every test wants this: {@link #anunrestorableSessionSendsYouBackToSignIn} needs the + * opposite. So it is opt-in rather than setup, and torn down either way. + */ + private void givenTheSessionRestores() { + if (!Python.isStarted()) { + Python.start(new AndroidPlatform(getInstrumentation().getTargetContext())); + } + this.appleDouble = Python.getInstance().getModule("apple_test_double"); + this.appleDouble.callAttr("installWithNothingToReport"); + } + + /** + * The same, but every fetch comes back with a location - so the fetch path runs too. + * + *

Two hours ago, which is inside every window the app asks for, so nothing here depends + * on which window a given path chose. + */ + private void givenTheAccountReports(final double latitude, final double longitude) { + if (!Python.isStarted()) { + Python.start(new AndroidPlatform(getInstrumentation().getTargetContext())); + } + this.appleDouble = Python.getInstance().getModule("apple_test_double"); + this.appleDouble.callAttr("install", latitude, longitude, 2.0); + } + + @After + public void putEverythingBack() { + if (this.scenario != null) { + this.scenario.close(); + } + if (this.appleDouble != null) { + this.appleDouble.callAttr("uninstall"); + } + MapProviderFactory.reset(); + this.forgetThem(); + this.guard.restore(); + } + + private void insert(final long importId, final String id, final String name) { + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(id).importId(importId).content(A_PLIST).version("0.0.2") + .fromAccount(false).isRemoved(false) + // **Stored, so nothing tries to derive it from the plist above.** That plist is + // shaped like a real one and is not one - its private key is eighteen bytes + // where a real key is twenty-eight - so FindMy.py refuses it, the request list + // comes back empty, and the fetch never happens. Silently: a conversion failure + // is logged per accessory and the batch carries on with what is left, which is + // nothing. What is in here does not matter, because apple_test_double replaces + // the function that reads it. + .accessoryJson("{\"type\": \"accessory\", \"id\": \"" + id + "\"}") + .build()); + + this.db.beaconNamingRecordDao().insertAll(BeaconNamingRecord.builder() + .id(id).importId(importId).version("0.0.2").isRemoved(false) + .content("" + + "identifier" + id + "" + + "name" + name + "" + + "") + .build()); + } + + private void forgetThem() { + for (final String id : new String[] {FOUND_TAG, NEVER_SEEN_TAG}) { + this.db.ownedBeaconDao().delete(OwnedBeacon.builder().id(id).build()); + this.db.beaconNamingRecordDao().delete(BeaconNamingRecord.builder().id(id).build()); + } + for (final Import stale : this.db.importDao().getImportsFromUser(A_TEST_USER)) { + this.db.importDao().delete(stale); + } + } + + private void openTheMap() { + this.scenario = ActivityScenario.launch(new Intent( + getInstrumentation().getTargetContext(), MapsActivity.class)); + } + + /** + * It starts at all. + * + *

Thin, and the single most valuable assertion here: until now nothing launched this + * screen, so any crash in {@code onCreate} - a missing view id, a null provider, a binding + * that no longer matches - was found by running the app rather than by running the suite. + */ + @Test + public void themapScreenStartsAndTakesTheProviderItIsGiven() { + this.openTheMap(); + + Eventually.check(() -> assertTrue("the map provider was never initialised", + this.fake.isReady())); + } + + /** + * A tag with a stored location gets a marker. + * + *

The assertion this screen most needed and could not have until the session restored. + * The double returns nothing from the fetch, so what is drawn here is what the database + * already held - which is the behaviour worth pinning: somebody opening the app sees where + * their things were before Apple is asked anything. + */ + @Test + public void astoredLocationIsDrawn() { + this.givenTheSessionRestores(); + this.openTheMap(); + + Eventually.check(() -> assertEquals("the stored tag was not drawn", + 1, this.markersFor(FOUND_TAG).size())); + } + + /** And where the report says, not merely somewhere. */ + @Test + public void themarkerIsAtTheStoredCoordinates() { + this.givenTheSessionRestores(); + this.openTheMap(); + + Eventually.check(() -> assertEquals(1, this.markersFor(FOUND_TAG).size())); + + final MapMarker drawn = this.markersFor(FOUND_TAG).get(0).marker; + + assertEquals(LATITUDE, drawn.getLatitude(), 0.000001); + assertEquals(LONGITUDE, drawn.getLongitude(), 0.000001); + } + + /** + * A tag nobody has ever walked past gets no marker. + * + *

Intended, and it looks like a fault from the outside - the warning-level log about a tag + * that cannot be drawn reads like one. Pinned so nobody later fixes the non-problem by + * putting a marker at 0,0, which is in the Atlantic. + */ + @Test + public void atagWithNoLocationIsNotDrawnAtAll() { + this.givenTheSessionRestores(); + this.openTheMap(); + + Eventually.check(() -> assertEquals(1, this.markersFor(FOUND_TAG).size())); + + assertTrue("a tag with no location was given a marker anyway", + this.markersFor(NEVER_SEEN_TAG).isEmpty()); + } + + /** + * Every marker the fake was asked to place for one beacon. + * + *

Matched on the marker's id, which is the beacon id. Not on the title: the screen + * does not set one - a pin carries the tag's emoji or icon as its bitmap and nothing else - + * so a title-based filter matches nothing and reads as "the map drew nothing", which is a + * long way from the truth and cost a debugging round here. + */ + private List markersFor(final String beaconId) { + return this.fake.markers().stream() + .filter(placed -> beaconId.equals(placed.marker.getId())) + .collect(Collectors.toList()); + } + + /** + * A fetched location replaces the stored one, and the pin moves. + * + *

The whole chain in one assertion: the session restores, the fetch runs through the real + * bridge, Python serialises what the account returned, the repository stores it, and the + * screen redraws the tag where it now is. Every step between the two doubles is shipping + * code. + * + *

The two coordinates are a continent apart on purpose. A fetch that silently returned + * the cached report would pass an "is there a marker" test perfectly. + */ + @Test + public void afetchedLocationMovesThePin() { + this.givenTheAccountReports(FETCHED_LATITUDE, FETCHED_LONGITUDE); + this.openTheMap(); + + // **Asked first, and kept.** When this failed, "the pin is in the wrong place" was true + // and useless - the fetch had never reached Python at all, because the request list came + // back empty and nothing says so out loud. One question separates "the fetch is wrong" + // from "there was no fetch", and it is worth a line. + Eventually.check(() -> assertTrue("python was never asked to fetch anything", + this.appleDouble.callAttr("howManyFetches").toInt() > 0)); + + Eventually.check(() -> { + assertEquals(1, this.markersFor(FOUND_TAG).size()); + + final MapMarker drawn = this.markersFor(FOUND_TAG).get(0).marker; + assertEquals("the pin is still on the cached location, so the fetch never landed", + FETCHED_LATITUDE, drawn.getLatitude(), 0.000001); + assertEquals(FETCHED_LONGITUDE, drawn.getLongitude(), 0.000001); + }); + } + + /** + * And redrawing replaces the pin rather than stacking another on it. + * + *

Reachable now only because the fetch is: the screen draws the cached location, then + * draws the fetched one over it, so a run of this test redraws the same tag for real. There + * must be one marker at the end, not two. + * + *

Worth an assertion of its own because the failure is invisible on a real map - two pins + * at nearly the same place look like one - and permanent, since nothing ever removes the + * stale one. + */ + @Test + public void redrawingReplacesThePinRatherThanStackingOne() { + this.givenTheAccountReports(FETCHED_LATITUDE, FETCHED_LONGITUDE); + this.openTheMap(); + + Eventually.check(() -> assertEquals(FETCHED_LATITUDE, + this.markersFor(FOUND_TAG).get(0).marker.getLatitude(), 0.000001)); + + assertEquals("the tag was drawn twice and the older pin was never removed", + 1, this.markersFor(FOUND_TAG).size()); + } + + /** + * A session that will not restore sends you back to sign in. + * + *

Deliberate, and narrow: {@code isAccountRestoreFailure} matches only + * {@code PythonAccountLoginException}, which is what a blob saved by FindMy 0.7.6 produces + * under 0.9.x. Leaving somebody on a map that can never refresh would be worse than asking + * them to sign in again. + * + *

Pinned because the narrowness is the load-bearing part. Widening that check to any + * restore failure would start throwing people out to login for transient reasons, and the + * cost of being wrong is a re-login against Apple - not something to trigger on a hiccup. + */ + @Test + public void anunrestorableSessionSendsYouBackToSignIn() { + this.openTheMap(); + + Eventually.check(() -> assertEquals( + "the map stayed open on a session that cannot ever refresh", + Lifecycle.State.DESTROYED, this.scenario.getState())); + } + +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ASilentTagSaysSoTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ASilentTagSaysSoTest.java new file mode 100644 index 00000000..7dcf379a --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ASilentTagSaysSoTest.java @@ -0,0 +1,259 @@ +package dev.wander.android.opentagviewer.ui.mydevices; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.espresso.Espresso; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.DeviceInfoActivity; +import dev.wander.android.opentagviewer.Eventually; +import dev.wander.android.opentagviewer.MyDevicesListActivity; +import dev.wander.android.opentagviewer.R; +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.Import; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; + +/** + * What a tag the app has given up on looks like, and what can be done about it. + * + *

The whole feature is invisible without this. Underneath, a silent tag is skipped by + * the scheduled fetches - which is right, because each one costs a full-history search that will + * not repay it - but skipping something quietly is indistinguishable from failing to look. A user + * sees a tag that never updates and an app that appears to have stopped trying, with no + * explanation and nothing to press. + * + *

So the two things asserted here are the two things that make the silence honest: the device + * list says why, and the tag page offers to look again. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class ASilentTagSaysSoTest { + + private static final String A_SILENT_TAG = "test-silent-tag"; + private static final String A_HEALTHY_TAG = "test-healthy-tag"; + private static final String SILENT_NAME = "Long Lost Backpack"; + private static final String HEALTHY_NAME = "Everyday Keys"; + private static final String A_TEST_USER = "silenttagtest@example.invalid"; + + private static final String A_PLIST = "" + + "" + + "batteryLevel1" + + "model" + + "pairingDate2025-02-27T20:03:32Z" + + "privateKeykey" + + "databm90LWEtcmVhbC1rZXk=" + + "productId21760" + + "stableIdentifier2001~#0~#A0" + + "systemVersion2.0.73" + + "vendorId76" + + ""; + + private OpenTagViewerDatabase db; + private ActivityScenario scenario; + private long importId; + + @Before + public void seedOneSilentTagAndOneHealthyOne() { + final Context context = getInstrumentation().getTargetContext(); + this.db = OpenTagViewerDatabase.getInstance(context); + this.forgetThem(); + + this.importId = this.db.importDao().insert(Import.builder() + .version("0.0.2").importedAt(1_700_000_000_000L).exportedAt(1_699_000_000_000L) + .sourceUser(A_TEST_USER).exportedVia("OpenTagViewer.wizard:test").build()); + + this.insert(A_SILENT_TAG, SILENT_NAME, 1_700_000_000_000L); + this.insert(A_HEALTHY_TAG, HEALTHY_NAME, null); + } + + @After + public void putEverythingBack() { + if (this.scenario != null) { + this.scenario.close(); + } + this.forgetThem(); + } + + private void insert(final String id, final String name, final Long ignoredAt) { + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(id).importId(this.importId).content(A_PLIST).version("0.0.2") + .fromAccount(false).isRemoved(false) + .ignoredAt(ignoredAt) + .fruitlessScans(ignoredAt == null ? 0 : 7) + .lastScanAt(ignoredAt) + .build()); + + this.db.beaconNamingRecordDao().insertAll(BeaconNamingRecord.builder() + .id(id).importId(this.importId).version("0.0.2").isRemoved(false) + .content("" + + "identifier" + id + "" + + "name" + name + "" + + "") + .build()); + } + + private void forgetThem() { + for (final String id : new String[] {A_SILENT_TAG, A_HEALTHY_TAG}) { + this.db.ownedBeaconDao().delete(OwnedBeacon.builder().id(id).build()); + this.db.beaconNamingRecordDao().delete(BeaconNamingRecord.builder().id(id).build()); + } + for (final Import stale : this.db.importDao().getImportsFromUser(A_TEST_USER)) { + this.db.importDao().delete(stale); + } + } + + /** + * Assert that the row for one named tag carries one particular subtitle. + * + *

Matched by row, not by text on the screen. A bare + * {@code withText(no_last_location_known)} passes only while exactly one row happens to say + * it - so it would go green with the two rows' subtitles swapped, and it fails with an + * ambiguous matcher rather than a disagreement the moment two rows agree. The name and the + * subtitle are not siblings either: the name sits a level deeper, beside the warning icon. + * So this finds the row containing the name and asks what else that row contains. + */ + private void assertRowFor(final String name, final int expectedSubtitle) { + final String expected = + getInstrumentation().getTargetContext().getString(expectedSubtitle); + + onView(allOf(withId(R.id.device_item_container), hasDescendant(withText(name)))) + .check(matches(hasDescendant(allOf( + withId(R.id.list_item_last_update), withText(expected))))); + } + + private void openTheTagPage(final String beaconId) { + final Intent intent = new Intent( + getInstrumentation().getTargetContext(), DeviceInfoActivity.class); + intent.putExtra("beaconId", beaconId); + this.scenario = ActivityScenario.launchActivityForResult(intent); + } + + /** + * The device list says why, rather than the generic line. + * + *

"No last location known" and "we have given up looking" are the same sentence to a + * reader and completely different situations: one resolves itself the next time somebody + * walks past the tag, the other never will unless they act. + */ + @Test + public void thedeviceListExplainsASilentTagRatherThanJustSayingNothingIsKnown() { + this.scenario = ActivityScenario.launch(MyDevicesListActivity.class); + + Eventually.check(() -> onView(withText(SILENT_NAME)).check(matches(isDisplayed()))); + onView(withText(R.string.tag_ignored_summary)).check(matches(isDisplayed())); + } + + /** + * And a healthy tag with no location still gets the ordinary line. + * + *

Scoped to that row's own subtitle, not to the text anywhere on screen. The first + * version matched {@code withText(no_last_location_known)} globally, which passes only + * because exactly one row happens to say it - so breaking the feature made this fail with an + * ambiguous matcher rather than with a disagreement, and it would equally have passed if the + * healthy row had said the wrong thing while some other row said the right one. + */ + @Test + public void ahealthyTagWithNoLocationIsNotDescribedAsGivenUpOn() { + this.scenario = ActivityScenario.launch(MyDevicesListActivity.class); + + Eventually.check(() -> onView(withText(HEALTHY_NAME)).check(matches(isDisplayed()))); + + this.assertRowFor(HEALTHY_NAME, R.string.no_last_location_known); + } + + /** And the silent one's line belongs to the silent one, not merely to the screen. */ + @Test + public void thesilentTagsOwnRowIsTheOneThatExplainsItself() { + this.scenario = ActivityScenario.launch(MyDevicesListActivity.class); + + Eventually.check(() -> onView(withText(SILENT_NAME)).check(matches(isDisplayed()))); + + this.assertRowFor(SILENT_NAME, R.string.tag_ignored_summary); + } + + /** The tag page explains it and offers the one action that can change it. */ + @Test + public void thetagPageOffersToLookAgain() { + this.openTheTagPage(A_SILENT_TAG); + + Eventually.check(() -> onView(withId(R.id.device_ignored_notice)) + .check(matches(isDisplayed()))); + onView(withId(R.id.device_ignored_retry)).check(matches(isDisplayed())); + } + + /** A healthy tag gets no notice at all - it has nothing to explain. */ + @Test + public void ahealthyTagPageHasNoSuchNotice() { + this.openTheTagPage(A_HEALTHY_TAG); + + Eventually.check(() -> onView(withId(R.id.device_settings_name)) + .check(matches(isDisplayed()))); + onView(withId(R.id.device_ignored_notice)).check(matches(not(isDisplayed()))); + } + + /** + * And pressing it asks for that tag by name. + * + *

The tag page cannot fetch - the Python service and the card that shows the answer both + * live on the map - so it hands the request back, which is also what puts the retry on the + * manual path the backoff cannot touch. What is asserted is the handover: the screen finishes + * naming the tag it wants looked for. + */ + @Test + public void pressingItAsksWhoeverOpenedTheScreenToLookForThatTag() { + this.openTheTagPage(A_SILENT_TAG); + + Eventually.check(() -> onView(withId(R.id.device_ignored_retry)) + .check(matches(isDisplayed()))); + onView(withId(R.id.device_ignored_retry)).perform(click()); + + Eventually.check(() -> assertEquals( + Activity.RESULT_OK, this.scenario.getResult().getResultCode())); + + final Intent handedBack = this.scenario.getResult().getResultData(); + assertNotNull("nothing was handed back, so nothing will look for the tag", handedBack); + assertEquals("the wrong tag was asked about", A_SILENT_TAG, + handedBack.getStringExtra(DeviceInfoActivity.RETRY_IGNORED_BEACON)); + } + + /** Leaving the page any other way must not ask for a search nobody requested. */ + @Test + public void leavingWithoutPressingItAsksForNothing() { + this.openTheTagPage(A_SILENT_TAG); + + Eventually.check(() -> onView(withId(R.id.device_ignored_notice)) + .check(matches(isDisplayed()))); + Espresso.pressBackUnconditionally(); + + Eventually.check(() -> assertNotNull(this.scenario.getResult())); + + final Intent handedBack = this.scenario.getResult().getResultData(); + final String asked = handedBack == null + ? null : handedBack.getStringExtra(DeviceInfoActivity.RETRY_IGNORED_BEACON); + + assertEquals("backing out must not trigger an expensive search", null, asked); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/GettingTagsInWhenTheListIsNotEmptyTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/GettingTagsInWhenTheListIsNotEmptyTest.java new file mode 100644 index 00000000..727c39a5 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/GettingTagsInWhenTheListIsNotEmptyTest.java @@ -0,0 +1,221 @@ +package dev.wander.android.opentagviewer.ui.mydevices; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.intent.Intents.intended; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent; +import static androidx.test.espresso.matcher.RootMatchers.isPlatformPopup; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.not; + +import android.app.Activity; +import android.app.Instrumentation; +import android.content.Context; +import android.content.Intent; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.espresso.intent.Intents; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.Eventually; +import dev.wander.android.opentagviewer.FetchFromICloudActivity; +import dev.wander.android.opentagviewer.MyDevicesListActivity; +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; +import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; +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.Import; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.icloud.KeychainMembership; +import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; + +/** + * Getting tags in, from a list that already has tags in it. + * + *

Both ways in used to disappear the moment the first one worked. The buttons live in + * the empty state, and the empty state is hidden as soon as anything is imported - so a user with + * one tag had no way to add a second, from a file or from their account, without going back to + * the map's menu for one of them and nowhere at all for the other. + * + *

It matters most for the account route, and that is why this exists rather than being folded + * into a broader UI test. The app joins the Apple keychain so that a later read costs one tap and + * no device passcode - a whole feature, several days of work, tested to the point of asserting + * that the passcode is never asked for twice - and until now there was no way to ask for that + * second read. The empty-state test cannot catch it, because in the empty state everything works. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class GettingTagsInWhenTheListIsNotEmptyTest { + + private static final String A_TAG = "test-already-imported-tag"; + private static final String A_NAME = "A Tag That Is Already Here"; + private static final String A_TEST_USER = "gettingtagsintest@example.invalid"; + + private static final String A_PLIST = "" + + "" + + "batteryLevel1" + + "model" + + "pairingDate2025-02-27T20:03:32Z" + + "privateKeykey" + + "databm90LWEtcmVhbC1rZXk=" + + "productId21760" + + "stableIdentifier2001~#0~#A0" + + "systemVersion2.0.73" + + "vendorId76" + + ""; + + private OpenTagViewerDatabase db; + private ActivityScenario scenario; + + @Before + public void seedOneTagSoTheListIsNotEmpty() { + final Context context = getInstrumentation().getTargetContext(); + this.db = OpenTagViewerDatabase.getInstance(context); + this.forgetIt(); + + final long importId = this.db.importDao().insert(Import.builder() + .version("0.0.2") + .importedAt(1_700_000_000_000L) + .exportedAt(1_699_000_000_000L) + .sourceUser(A_TEST_USER) + .exportedVia("OpenTagViewer.wizard:test") + .build()); + + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(A_TAG).importId(importId).content(A_PLIST).version("0.0.2") + .fromAccount(false).isRemoved(false).build()); + + this.db.beaconNamingRecordDao().insertAll(BeaconNamingRecord.builder() + .id(A_TAG).importId(importId).version("0.0.2").isRemoved(false) + .content("" + + "identifier" + A_TAG + "" + + "name" + A_NAME + "" + + "") + .build()); + + Intents.init(); + // The fetch screen wants an Apple session and a Python interpreter. Nothing here is + // about what it does - only that it is what the menu item reaches - so it is answered + // at the door rather than launched. + Intents.intending(hasComponent(FetchFromICloudActivity.class.getName())) + .respondWith(new Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null)); + } + + @After + public void putEverythingBack() { + Intents.release(); + if (this.scenario != null) { + this.scenario.close(); + } + this.forgetIt(); + new KeychainMembershipRepository( + UserAuthDataStore.getInstance(getInstrumentation().getTargetContext()), + new AppCryptographyUtil()).forget().blockingAwait(); + } + + /** As if the app had already joined the account's keychain. */ + private void givenTheAccountIsLinked() { + new KeychainMembershipRepository( + UserAuthDataStore.getInstance(getInstrumentation().getTargetContext()), + new AppCryptographyUtil()) + .store(new KeychainMembership( + "{\"peer_id\":\"peer-ours\"}", "ZW50cm9weQ==", "a-passcode", + "a-label", 2)) + .blockingAwait(); + } + + private void forgetIt() { + this.db.ownedBeaconDao().delete(OwnedBeacon.builder().id(A_TAG).build()); + this.db.beaconNamingRecordDao().delete(BeaconNamingRecord.builder().id(A_TAG).build()); + for (final Import stale : this.db.importDao().getImportsFromUser(A_TEST_USER)) { + this.db.importDao().delete(stale); + } + } + + private void openTheListWithATagInIt() { + this.scenario = ActivityScenario.launch(MyDevicesListActivity.class); + Eventually.check(() -> onView(withText(A_NAME)).check(matches(isDisplayed()))); + } + + /** The premise: with a tag in the list, the empty state and its buttons are gone. */ + @Test + public void theemptyStateButtonsAreNotOnScreenOnceSomethingIsImported() { + this.openTheListWithATagInIt(); + + onView(withId(R.id.my_devices_empty_state)).check(matches(not(isDisplayed()))); + onView(withId(R.id.my_devices_empty_fetch_button)).check(matches(not(isDisplayed()))); + } + + @Test + public void theoverflowMenuOffersBothWaysIn() { + this.openTheListWithATagInIt(); + + onView(withId(R.id.page_menu_button)).perform(click()); + + Eventually.check(() -> onView(withText(R.string.icloud_link_account_action)) + .inRoot(isPlatformPopup()).check(matches(isDisplayed()))); + onView(withText(R.string.icloud_import_from_file)) + .inRoot(isPlatformPopup()).check(matches(isDisplayed())); + } + + /** + * And linking disappears once the account is linked. + * + *

The item links an account; after that the app is a member of the keychain and re-reads + * without asking for anything, so offering to link again describes work already done. + * Importing a file stays, because somebody with a linked account can still be handed a + * bundle for a tag that is not theirs. + */ + @Test + public void linkingIsNotOfferedOnceTheAccountIsLinked() { + this.givenTheAccountIsLinked(); + + this.openTheListWithATagInIt(); + Eventually.check(() -> onView(withId(R.id.page_menu_button)) + .check(matches(isDisplayed()))); + onView(withId(R.id.page_menu_button)).perform(click()); + + Eventually.check(() -> onView(withText(R.string.icloud_import_from_file)) + .inRoot(isPlatformPopup()).check(matches(isDisplayed()))); + onView(withText(R.string.icloud_link_account_action)).check(doesNotExist()); + } + + /** The one that matters. A second read of the account is one tap away. */ + @Test + public void thefetchItemReachesTheAccountScreen() { + this.openTheListWithATagInIt(); + + onView(withId(R.id.page_menu_button)).perform(click()); + Eventually.check(() -> onView(withText(R.string.icloud_link_account_action)) + .inRoot(isPlatformPopup()).check(matches(isDisplayed()))); + onView(withText(R.string.icloud_link_account_action)) + .inRoot(isPlatformPopup()).perform(click()); + + Eventually.check(() -> intended(hasComponent(FetchFromICloudActivity.class.getName()))); + } + + /** And the empty state's own button still goes to the same place. */ + @Test + public void theemptyStateButtonStillWorks() { + this.db.ownedBeaconDao().delete(OwnedBeacon.builder().id(A_TAG).build()); + + this.scenario = ActivityScenario.launch(MyDevicesListActivity.class); + Eventually.check(() -> onView(withId(R.id.my_devices_empty_fetch_button)) + .check(matches(isDisplayed()))); + onView(withId(R.id.my_devices_empty_fetch_button)).perform(click()); + + Eventually.check(() -> intended(hasComponent(FetchFromICloudActivity.class.getName()))); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/RemoveAccountTagTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/RemoveAccountTagTest.java new file mode 100644 index 00000000..f36e98fa --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/RemoveAccountTagTest.java @@ -0,0 +1,445 @@ +package dev.wander.android.opentagviewer.ui.mydevices; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.longClick; +import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.RootMatchers.isDialog; +import static androidx.test.espresso.matcher.RootMatchers.isPlatformPopup; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.content.Intent; + +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.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.DeviceInfoActivity; +import dev.wander.android.opentagviewer.Eventually; +import dev.wander.android.opentagviewer.MyDevicesListActivity; +import dev.wander.android.opentagviewer.R; +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.Import; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; + +/** + * Removing a tag the app does not own. + * + *

The failure this prevents is silent and self-undoing. A tag read from the Apple + * account is a cache of what Apple holds: marking it removed appears to work, the row leaves the + * list, and then the next refresh writes it back with {@code is_removed = 0} and the tag returns + * with no explanation at all. Nothing logs, nothing throws, and the user is left believing the + * app ignored them. + * + *

So the destructive button is not offered for one. What replaces it is an explanation of + * where the tag can actually be removed - in Find My, on an Apple device - because "not + * supported" with no next step is only marginally better than the lie. + * + *

Seeds the real on-device database rather than an in-memory one, because + * {@link OpenTagViewerDatabase#getInstance} is a plain singleton with no seam and this has to + * drive the real activity. Every row it writes is deleted by id, before and after - before as + * well, because a test that crashes half way leaves its rows behind and the next class to run + * inherits them. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class RemoveAccountTagTest { + + private static final String FROM_ACCOUNT = "test-account-tag"; + private static final String FROM_A_FILE = "test-file-tag"; + private static final String ALSO_FROM_ACCOUNT = "test-account-tag-2"; + + private static final String ACCOUNT_NAME = "Tag On The Account"; + private static final String FILE_NAME = "Tag From A Zip"; + private static final String OTHER_ACCOUNT_NAME = "Other Account Tag"; + + /** Distinctive enough that the cleanup cannot match a real import. */ + private static final String A_TEST_USER = "removeaccounttagtest@example.invalid"; + + private static final String A_PLIST = "" + + "" + + "batteryLevel1" + + "model" + + "pairingDate2025-02-27T20:03:32Z" + + "privateKeykey" + + "databm90LWEtcmVhbC1rZXk=" + + "productId21760" + + "stableIdentifier2001~#0~#A0" + + "systemVersion2.0.73" + + "vendorId76" + + ""; + + private OpenTagViewerDatabase db; + private ActivityScenario scenario; + private long importId; + + @Before + public void seedTheDatabase() { + final Context context = getInstrumentation().getTargetContext(); + this.db = OpenTagViewerDatabase.getInstance(context); + + this.removeEverythingThisTestWrites(); + + // A file-imported tag has an `Import` row behind it, and the device info screen reads it + // for three of its fields. Seeding one without it made the tag look like an account tag + // to any code that keys off the absence of an import - which is a mistake this test + // caught in the first draft of the change it is testing. + this.importId = this.db.importDao().insert(Import.builder() + .version("0.0.2") + .importedAt(1_700_000_000_000L) + .exportedAt(1_699_000_000_000L) + .sourceUser(A_TEST_USER) + .exportedVia("OpenTagViewer.wizard:test") + .build()); + + this.insert(FROM_ACCOUNT, ACCOUNT_NAME, true); + this.insert(FROM_A_FILE, FILE_NAME, false); + this.insert(ALSO_FROM_ACCOUNT, OTHER_ACCOUNT_NAME, true); + } + + @After + public void putTheDatabaseBack() { + if (this.scenario != null) { + this.scenario.close(); + } + this.removeEverythingThisTestWrites(); + } + + private void insert(final String id, final String name, final boolean fromAccount) { + // Null import id for an account tag, exactly as `refreshAccountBeacons` writes it - + // nothing was exported and nothing was imported, so there is no bundle to point at. + final Long belongsTo = fromAccount ? null : this.importId; + + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(id) + .importId(belongsTo) + .content(A_PLIST) + .version(fromAccount ? "account" : "0.0.2") + .fromAccount(fromAccount) + .isRemoved(false) + .build()); + + this.db.beaconNamingRecordDao().insertAll(BeaconNamingRecord.builder() + .id(id) + .importId(belongsTo) + .version(fromAccount ? "account" : "0.0.2") + .isRemoved(false) + .content("" + + "identifier" + id + "" + + "name" + name + "" + + "") + .build()); + } + + private void removeEverythingThisTestWrites() { + // By id, never `clearAllTables`. This is the real database - on a developer's own device + // that would take their imported tags and every location ever fetched for them. + for (final String id : new String[] {FROM_ACCOUNT, FROM_A_FILE, ALSO_FROM_ACCOUNT}) { + this.db.ownedBeaconDao().delete(OwnedBeacon.builder().id(id).build()); + this.db.beaconNamingRecordDao().delete(BeaconNamingRecord.builder().id(id).build()); + } + + // Found by this test's own source user rather than by a remembered id, so a run that + // crashed before @After does not leave one behind for the next. + for (final Import stale : this.db.importDao().getImportsFromUser(A_TEST_USER)) { + this.db.importDao().delete(stale); + } + } + + private boolean isStillListed(final String id) { + return this.db.ownedBeaconDao().getById(id) != null; + } + + private void openTheList() { + this.scenario = ActivityScenario.launch(MyDevicesListActivity.class); + Eventually.check(() -> onView(withText(ACCOUNT_NAME)).check(matches(isDisplayed()))); + } + + /** Long press a row, then choose Remove from the selection menu. */ + private void chooseRemoveAfterSelecting(final String... names) { + onView(withText(names[0])).perform(longClick()); + for (int i = 1; i < names.length; i++) { + onView(withText(names[i])).perform(click()); + } + + Eventually.check(() -> onView(withId(R.id.selection_menu_button)) + .check(matches(isDisplayed()))); + onView(withId(R.id.selection_menu_button)).perform(click()); + + onView(withText(R.string.remove_devices)).inRoot(isPlatformPopup()).perform(click()); + } + + /** + * The one that matters. No confirm button, and the tag is still there afterwards. + */ + @Test + public void anaccountTagIsExplainedRatherThanRemoved() { + this.openTheList(); + this.chooseRemoveAfterSelecting(ACCOUNT_NAME); + + Eventually.check(() -> onView(withText(R.string.cannot_remove_account_tag_title)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + onView(withText(R.string.confirm)).inRoot(isDialog()).check(doesNotExist()); + + onView(withText(R.string.ok)).inRoot(isDialog()).perform(click()); + Eventually.check(() -> assertNotNull("the account tag must still be there", + this.db.ownedBeaconDao().getById(FROM_ACCOUNT))); + } + + /** Two of them says "these", not "this". */ + @Test + public void twoaccountTagsGetThePluralExplanation() { + this.openTheList(); + this.chooseRemoveAfterSelecting(ACCOUNT_NAME, OTHER_ACCOUNT_NAME); + + Eventually.check(() -> onView(withText(R.string.cannot_remove_account_tags_message)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + } + + /** + * The behaviour that already worked still works. + * + *

Worth its own test: the cheap way to implement this change is to disable the menu item + * whenever any account tag exists, which would take removal away from everybody. + */ + @Test + public void afileImportedTagIsStillRemovable() { + this.openTheList(); + this.chooseRemoveAfterSelecting(FILE_NAME); + + Eventually.check(() -> onView(withText(R.string.confirm)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + onView(withText(R.string.confirm)).inRoot(isDialog()).perform(click()); + + Eventually.check(() -> assertNull("the file-imported tag should have been removed", + this.db.ownedBeaconDao().getById(FROM_A_FILE))); + } + + /** + * A mixed selection removes what it can and says what it did not. + * + *

The alternative - refusing the whole selection - punishes somebody for picking one tag + * too many, and the alternative to that is removing the file ones silently, which is + * how a user ends up staring at a tag they thought they deleted. + */ + @Test + public void amixedSelectionRemovesOnlyWhatTheAppOwns() { + this.openTheList(); + this.chooseRemoveAfterSelecting(FILE_NAME, ACCOUNT_NAME); + + Eventually.check(() -> onView(withText( + getInstrumentation().getTargetContext() + .getString(R.string.remove_devices_but_keep_account_ones, 1))) + .inRoot(isDialog()).check(matches(isDisplayed()))); + + onView(withText(R.string.confirm)).inRoot(isDialog()).perform(click()); + + Eventually.check(() -> assertNull("the file-imported tag should have gone", + this.db.ownedBeaconDao().getById(FROM_A_FILE))); + assertNotNull("the account tag should have stayed", + this.db.ownedBeaconDao().getById(FROM_ACCOUNT)); + } + + /** The same answer from the other screen that offers removal. */ + @Test + public void thedeviceInfoScreenSaysTheSameThing() { + final Intent intent = new Intent( + getInstrumentation().getTargetContext(), DeviceInfoActivity.class); + intent.putExtra("beaconId", FROM_ACCOUNT); + this.scenario = ActivityScenario.launch(intent); + + Eventually.check(() -> onView(withId(R.id.page_menu_button)) + .check(matches(isDisplayed()))); + onView(withId(R.id.page_menu_button)).perform(click()); + onView(withText(R.string.remove_device)).inRoot(isPlatformPopup()).perform(click()); + + Eventually.check(() -> onView(withText(R.string.cannot_remove_account_tag_title)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + onView(withText(R.string.confirm)).inRoot(isDialog()).check(doesNotExist()); + + onView(withText(R.string.ok)).inRoot(isDialog()).perform(click()); + Eventually.check(() -> assertNotNull(this.db.ownedBeaconDao().getById(FROM_ACCOUNT))); + } + + /** + * The screen says where the tag came from instead of describing a bundle that never existed. + * + *

"Exported by", "Exported at" and "Imported at" all read from an {@code Import} row, and + * an account tag has none - which is what used to crash this screen outright. Leaving the + * three rows in place with blank subtitles would have replaced a crash with three fields that + * look like data the app failed to load, so exactly one of the two sets is shown. + */ + @Test + public void anaccountTagSaysWhereItCameFromInsteadOfHowItWasExported() { + final Intent intent = new Intent( + getInstrumentation().getTargetContext(), DeviceInfoActivity.class); + intent.putExtra("beaconId", FROM_ACCOUNT); + this.scenario = ActivityScenario.launch(intent); + + Eventually.check(() -> onView(withText(R.string.source_your_apple_account)) + .check(matches(isDisplayed()))); + + onView(withId(R.id.device_settings_exported_by)).check(matches(not(isDisplayed()))); + onView(withId(R.id.device_settings_exported_at)).check(matches(not(isDisplayed()))); + onView(withId(R.id.device_settings_imported_at)).check(matches(not(isDisplayed()))); + + this.capture("tag_page-from_account.png"); + } + + /** + * What the overflow menu offers for an account tag, and what tapping it says. + * + *

Remove is listed rather than hidden or greyed out, matching what + * {@code device_selection_menu.xml} already does with Export Tag: a menu that changes shape + * between one tag and the next is harder to learn than one where every item is always there. + * A disabled item would also be a dead end - it is the explanation, not the absence of the + * button, that tells somebody where to go. + * + *

Pictures only; the behaviour is asserted by the two tests above. + */ + @Test + public void thepictureOfTheMenuAndWhatItSays() { + final Intent intent = new Intent( + getInstrumentation().getTargetContext(), DeviceInfoActivity.class); + intent.putExtra("beaconId", FROM_ACCOUNT); + this.scenario = ActivityScenario.launch(intent); + + Eventually.check(() -> onView(withId(R.id.page_menu_button)) + .check(matches(isDisplayed()))); + onView(withId(R.id.page_menu_button)).perform(click()); + Eventually.check(() -> onView(withText(R.string.remove_device)) + .inRoot(isPlatformPopup()).check(matches(isDisplayed()))); + this.captureWindowShowing(withText(R.string.remove_device), "tag_page-menu.png"); + + onView(withText(R.string.remove_device)).inRoot(isPlatformPopup()).perform(click()); + Eventually.check(() -> onView(withText(R.string.cannot_remove_account_tag_title)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + this.captureWindowShowing( + withText(R.string.cannot_remove_account_tag_title), "tag_page-cannot_remove.png"); + } + + /** + * The window as it stands, for a person to look at. + * + *

Not an assertion - every one of these screens is asserted on separately. It is here + * because "does the new row read like a fact or like a field that failed to load" is a + * question a picture answers and a matcher does not. + */ + private void capture(final String name) { + final Bitmap[] shot = new Bitmap[1]; + this.scenario.onActivity(activity -> shot[0] = draw(activity.getWindow().getDecorView())); + write(shot[0], name); + } + + /** + * The same, for something that is not in the activity's window. + * + *

A popup menu and an alert dialog each get a window of their own, so drawing the + * activity's decor view photographs the screen behind them - a plausible-looking picture of + * the wrong thing. {@code UiAutomation.takeScreenshot()} is the obvious answer and is worse: + * on the headless managed device it returned the same blank bitmap for every call, four + * byte-identical files, which is a screenshot test that can never fail. + * + *

So this reaches a view inside the window and draws upward from it. Whatever root that + * view belongs to is the window it is in, by definition. + */ + private void captureWindowShowing(final org.hamcrest.Matcher inside, + final String name) { + final Bitmap[] shot = new Bitmap[1]; + onView(inside).perform(new androidx.test.espresso.ViewAction() { + @Override + public org.hamcrest.Matcher getConstraints() { + return isDisplayed(); + } + + @Override + public String getDescription() { + return "draw the window this view is in"; + } + + @Override + public void perform(final androidx.test.espresso.UiController controller, + final android.view.View view) { + controller.loopMainThreadUntilIdle(); + shot[0] = draw(view.getRootView()); + } + }); + write(shot[0], name); + } + + private static Bitmap draw(final android.view.View view) { + final Bitmap bitmap = Bitmap.createBitmap( + Math.max(view.getWidth(), 1), Math.max(view.getHeight(), 1), + Bitmap.Config.ARGB_8888); + view.draw(new Canvas(bitmap)); + return bitmap; + } + + private static void write(final Bitmap bitmap, final String name) { + final String directory = androidx.test.platform.app.InstrumentationRegistry + .getArguments().getString("additionalTestOutputDir"); + if (directory == null || bitmap == null) { + return; + } + + try (java.io.FileOutputStream out = + new java.io.FileOutputStream(new java.io.File(directory, name))) { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); + } catch (final java.io.IOException e) { + throw new AssertionError("could not write " + name, e); + } + } + + /** And a tag that did come from a bundle still describes the bundle. */ + @Test + public void afileImportedTagStillShowsItsExportDetails() { + final Intent intent = new Intent( + getInstrumentation().getTargetContext(), DeviceInfoActivity.class); + intent.putExtra("beaconId", FROM_A_FILE); + this.scenario = ActivityScenario.launch(intent); + + Eventually.check(() -> onView(withId(R.id.device_settings_exported_by)) + .check(matches(isDisplayed()))); + + onView(withId(R.id.device_settings_imported_at)).check(matches(isDisplayed())); + onView(withId(R.id.device_settings_source)).check(matches(not(isDisplayed()))); + + this.capture("tag_page-from_a_file.png"); + } + + /** And it still offers it for a tag that is the app's own. */ + @Test + public void thedeviceInfoScreenStillOffersRemovalForAFileImportedTag() { + final Intent intent = new Intent( + getInstrumentation().getTargetContext(), DeviceInfoActivity.class); + intent.putExtra("beaconId", FROM_A_FILE); + this.scenario = ActivityScenario.launch(intent); + + Eventually.check(() -> onView(withId(R.id.page_menu_button)) + .check(matches(isDisplayed()))); + onView(withId(R.id.page_menu_button)).perform(click()); + onView(withText(R.string.remove_device)).inRoot(isPlatformPopup()).perform(click()); + + Eventually.check(() -> onView(withText(R.string.confirm)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + assertTrue("nothing should have been removed by opening the dialog", + this.isStillListed(FROM_A_FILE)); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/RenamingWritesToTheAccountTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/RenamingWritesToTheAccountTest.java new file mode 100644 index 00000000..94cc7585 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/RenamingWritesToTheAccountTest.java @@ -0,0 +1,440 @@ +package dev.wander.android.opentagviewer.ui.mydevices; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.RootMatchers.isDialog; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertArrayEquals; +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 android.content.Context; +import android.content.Intent; +import android.view.View; + +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.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.DeviceInfoActivity; +import dev.wander.android.opentagviewer.Eventually; +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.TestPace; +import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; +import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; +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.Import; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.HardwareDescriber; +import dev.wander.android.opentagviewer.python.icloud.FakeICloudService; +import dev.wander.android.opentagviewer.python.icloud.ICloudFailure; +import dev.wander.android.opentagviewer.python.icloud.KeychainMembership; +import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; + +/** + * Renaming a tag, which does two completely different things depending on the tag. + * + *

The rule, and it is genuinely odd: + * + *

    + *
  • An accessory read from iCloud - an AirTag, or a Find My-certified tag - keeps its + * name and emoji in the naming record and nowhere else. So renaming it writes to the + * account, and the owner sees the new name in Find My on their own devices.
  • + *
  • One of the owner's own devices - an iPhone, iPad or Mac - takes its name from + * several places at once. Writing this record would leave Find My disagreeing with the + * device itself, so the app keeps a local nickname and goes on showing the real name.
  • + *
  • Anything imported from a file was never on this account. Nickname, as before.
  • + *
+ * + *

Which is why these tests exist rather than one happy-path check. Both behaviours look + * identical on screen - a tag with a new name on it - and the difference is whether a write left + * the device. The failure mode of getting it wrong is not a crash: it is either a rename that + * quietly does not reach the account, or a write to somebody's Apple account they did not ask + * for. Neither shows up by looking. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class RenamingWritesToTheAccountTest { + + private static final String AN_ACCESSORY = "test-rename-accessory"; + private static final String A_DEVICE = "test-rename-device"; + private static final String FROM_A_FILE = "test-rename-file-tag"; + + private static final String THE_OLD_NAME = "Old Tag Name"; + private static final String THE_NEW_NAME = "Renamed In iCloud"; + private static final String A_TEST_USER = "renametest@example.invalid"; + + private static final String A_PLIST = "" + + "" + + "batteryLevel1" + + "model" + + "pairingDate2025-02-27T20:03:32Z" + + "privateKeykey" + + "databm90LWEtcmVhbC1rZXk=" + + "productId21760" + + "stableIdentifier2001~#0~#A0" + + "systemVersion2.0.73" + + "vendorId76" + + ""; + + /** Answers the one question that decides which behaviour the screen offers. */ + private static final class Describer implements HardwareDescriber { + private final Boolean ownDevice; + + Describer(final Boolean ownDevice) { + this.ownDevice = ownDevice; + } + + @Override + public String describe(final String plistXml) { + return "AirTag"; + } + + @Override + public String whereToLookUp(final String plistXml) { + return null; + } + + @Override + public Boolean isOwnDevice(final String plistXml) { + return this.ownDevice; + } + } + + private OpenTagViewerDatabase db; + private KeychainMembershipRepository memberships; + private FakeICloudService icloud; + private ActivityScenario scenario; + private long importId; + + @Before + public void seedThreeKindsOfTag() { + final Context context = getInstrumentation().getTargetContext(); + this.db = OpenTagViewerDatabase.getInstance(context); + this.memberships = new KeychainMembershipRepository( + UserAuthDataStore.getInstance(context), new AppCryptographyUtil()); + + this.forgetEverything(); + + this.importId = this.db.importDao().insert(Import.builder() + .version("0.0.2") + .importedAt(1_700_000_000_000L) + .exportedAt(1_699_000_000_000L) + .sourceUser(A_TEST_USER) + .exportedVia("OpenTagViewer.wizard:test") + .build()); + + this.insert(AN_ACCESSORY, true); + this.insert(A_DEVICE, true); + this.insert(FROM_A_FILE, false); + + // Renaming through the account resumes as the member this app already is. Without one + // stored there is nothing to resume with, and every write would fail for that reason + // rather than the one under test. + this.memberships.store(new KeychainMembership( + "{\"peer_id\":\"peer-ours\"}", "ZW50cm9weQ==", "a-generated-passcode", + "a-label", 2)).blockingAwait(); + } + + @After + public void putEverythingBack() { + if (this.scenario != null) { + this.scenario.close(); + } + AppDependencies.reset(); + this.forgetEverything(); + } + + private void insert(final String id, final boolean fromAccount) { + final Long belongsTo = fromAccount ? null : this.importId; + + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(id).importId(belongsTo).content(A_PLIST) + .version(fromAccount ? "account" : "0.0.2") + .fromAccount(fromAccount).isRemoved(false).build()); + + this.db.beaconNamingRecordDao().insertAll(BeaconNamingRecord.builder() + .id(id).importId(belongsTo) + .version(fromAccount ? "account" : "0.0.2").isRemoved(false) + .content("" + + "identifier" + id + "" + + "name" + THE_OLD_NAME + "" + + "") + .build()); + } + + private void forgetEverything() { + for (final String id : new String[] {AN_ACCESSORY, A_DEVICE, FROM_A_FILE}) { + this.db.ownedBeaconDao().delete(OwnedBeacon.builder().id(id).build()); + this.db.beaconNamingRecordDao().delete(BeaconNamingRecord.builder().id(id).build()); + this.db.userBeaconOptionsDao().deleteById(id); + } + for (final Import stale : this.db.importDao().getImportsFromUser(A_TEST_USER)) { + this.db.importDao().delete(stale); + } + this.memberships.forget().blockingAwait(); + } + + private void open(final String beaconId, final Boolean ownDevice) { + this.icloud = FakeICloudService.withTags(); + AppDependencies.replaceICloud(() -> this.icloud); + AppDependencies.replaceHardwareDescriber(new Describer(ownDevice)); + + final Intent intent = new Intent( + getInstrumentation().getTargetContext(), DeviceInfoActivity.class); + intent.putExtra("beaconId", beaconId); + this.scenario = ActivityScenario.launch(intent); + + // By id, not by text: the name is on screen in three places at once - the toolbar + // title, the row, and the debug section - and matching the text is ambiguous. + Eventually.check(() -> onView(withId(R.id.device_settings_name)) + .check(matches(isDisplayed()))); + TestPace.afterAStep(); + } + + /** Tap the name row, type a new one, confirm. */ + private void renameTo(final String newName) { + onView(withId(R.id.device_settings_name)).perform(click()); + TestPace.afterAStep(); + + Eventually.check(() -> onView(withId(R.id.device_name_input)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + onView(withId(R.id.device_name_input)).inRoot(isDialog()).perform(replaceText(newName)); + TestPace.afterAStep(); + + onView(withText(R.string.confirm)).inRoot(isDialog()).perform(click()); + TestPace.afterAStep(); + } + + private String storedNameFor(final String beaconId) { + return this.db.beaconNamingRecordDao().getByBeaconId(beaconId).content; + } + + /** + * An accessory read from iCloud has no "original" name to show. + * + *

Renaming it writes to the account, so the name on screen is the name. A debug row + * labelled "original" showing the identical string invites the reader to hunt for a + * difference that cannot exist - which is worse than showing nothing, because it looks like + * information. + * + *

Asked of the view's visibility rather than through Espresso, because these rows live + * inside the debug block and that is hidden unless debug data is switched on. What is being + * checked is the decision, not whether the debug section happens to be open. + */ + @Test + public void anaccessoryHasNoOriginalNameRowsBecauseItsNameIsNotANickname() { + this.open(AN_ACCESSORY, false); + + Eventually.check(() -> assertEquals("an accessory's name is not a nickname, so there is" + + " no original to show alongside it", + View.GONE, this.visibilityOf(R.id.settings_debug_device_name_original))); + assertEquals(View.GONE, this.visibilityOf(R.id.settings_debug_device_emoji_original)); + } + + /** One of the owner's own devices keeps them - there, the name really is layered. */ + @Test + public void anownDeviceStillShowsWhatAppleCallsIt() { + this.open(A_DEVICE, true); + + Eventually.check(() -> assertEquals("a nicknamed device must still show its real name", + View.VISIBLE, this.visibilityOf(R.id.settings_debug_device_name_original))); + } + + /** And so does anything imported from a file, for the same reason. */ + @Test + public void afileImportedTagStillShowsItsOriginalName() { + this.open(FROM_A_FILE, false); + + Eventually.check(() -> assertEquals( + View.VISIBLE, this.visibilityOf(R.id.settings_debug_device_name_original))); + } + + /** + * The battery row carries its caveat, on every tag. + * + *

Apple's own devices are what update that field as they pass an accessory, so a tag + * imported from a zip keeps whatever value was true when the export was made and never + * changes it. A bare number reads as current, and "Full" on a tag flat since last spring is + * worse than showing nothing at all. + * + *

Checked on an account tag deliberately - the one where the value is live. Showing + * the rule only where it bites would mean nobody learns it until they are already misreading + * an imported tag. + */ + @Test + public void thebatteryRowSaysWhoEverUpdatesIt() { + this.open(AN_ACCESSORY, false); + + final String[] shown = {null}; + Eventually.check(() -> { + this.scenario.onActivity(activity -> shown[0] = + ((android.widget.TextView) activity + .findViewById(R.id.settings_debug_naming_record_battery_level) + .findViewById(R.id.settings_clickable_item_content)).getText().toString()); + assertNotNull(shown[0]); + }); + + assertTrue("the battery row must say that only iCloud updates it, but read: " + shown[0], + shown[0].contains(getInstrumentation().getTargetContext() + .getString(R.string.battery_level_icloud_only))); + } + + private int visibilityOf(final int id) { + final int[] visibility = {-1}; + this.scenario.onActivity(activity -> visibility[0] = activity.findViewById(id).getVisibility()); + return visibility[0]; + } + + /** + * An accessory's rename goes to Apple. + */ + @Test + public void anaccessoryFromICloudIsRenamedInTheAccount() { + this.open(AN_ACCESSORY, false); + + this.renameTo(THE_NEW_NAME); + + Eventually.check(() -> assertEquals(1, this.icloud.timesCalled("rename"))); + assertArrayEquals("the wrong thing was sent to be renamed", + new String[] {AN_ACCESSORY, THE_NEW_NAME, ""}, this.icloud.renamedWith()); + } + + /** + * And the record it was judged from is this tag's, not an empty string. + * + *

Python decides accessory-or-device from that plist. Sending the wrong one - or none - + * would have it answering about a different tag, and the rename would still look fine. + */ + @Test + public void therecordSentIsTheOneTheTagWasImportedWith() { + this.open(AN_ACCESSORY, false); + + this.renameTo(THE_NEW_NAME); + + Eventually.check(() -> assertNotNull(this.icloud.renamedPlist())); + assertTrue("the accessory's own record must be what the decision is made from", + this.icloud.renamedPlist().contains("stableIdentifier")); + } + + /** + * The new name becomes the tag's real name, not a nickname over the top of it. + * + *

A nickname would have been much less code and quietly wrong: it wins at display time + * forever, so the next rename made on the owner's iPhone would arrive and be hidden behind + * it, and the app would look like it had stopped syncing. + */ + @Test + public void thestoredRecordIsRewrittenRatherThanNicknamed() { + this.open(AN_ACCESSORY, false); + + this.renameTo(THE_NEW_NAME); + + Eventually.check(() -> assertTrue("the naming record still says the old name", + this.storedNameFor(AN_ACCESSORY).contains(THE_NEW_NAME))); + assertNull("a nickname must not be left over the real name", + this.db.userBeaconOptionsDao().getById(AN_ACCESSORY)); + } + + /** + * One of the owner's own devices is nicknamed, and nothing is written. + */ + @Test + public void anownDeviceIsNicknamedAndTheAccountIsNotTouched() { + this.open(A_DEVICE, true); + + this.renameTo("My Own iPad"); + + Eventually.check(() -> assertNotNull("the nickname was not saved", + this.db.userBeaconOptionsDao().getById(A_DEVICE))); + assertEquals("a device rename must never reach the account", + 0, this.icloud.timesCalled("rename")); + } + + /** And its real name is still there, under the nickname. */ + @Test + public void anownDevicesRealNameSurvivesTheNickname() { + this.open(A_DEVICE, true); + + this.renameTo("My Own iPad"); + + Eventually.check(() -> assertNotNull(this.db.userBeaconOptionsDao().getById(A_DEVICE))); + assertTrue("the real name must not be overwritten by a nickname", + this.storedNameFor(A_DEVICE).contains(THE_OLD_NAME)); + } + + /** A tag that came from a zip was never on this account, whatever kind of thing it is. */ + @Test + public void afileImportedTagIsNicknamedEvenThoughItIsAnAccessory() { + this.open(FROM_A_FILE, false); + + this.renameTo("From My Friend"); + + Eventually.check(() -> assertNotNull(this.db.userBeaconOptionsDao().getById(FROM_A_FILE))); + assertEquals("a file-imported tag is not on this account to rename", + 0, this.icloud.timesCalled("rename")); + } + + /** + * Before the heuristic has answered, renaming stays local. + * + *

Null is not false. The cautious mistake is a nickname, which changes nothing anybody + * else can see; the other one writes to somebody's Apple account on a guess. + */ + @Test + public void anunansweredHeuristicDoesNotWriteToTheAccount() { + this.open(AN_ACCESSORY, null); + + this.renameTo(THE_NEW_NAME); + + Eventually.check(() -> assertNotNull(this.db.userBeaconOptionsDao().getById(AN_ACCESSORY))); + assertEquals(0, this.icloud.timesCalled("rename")); + } + + /** + * A rename that could not reach Apple says so and changes nothing. + * + *

Not a silent demotion to a nickname. That would be the app telling the user something + * about their account that is not true, which is the same class of quiet lie as a remove + * button that undoes itself. + */ + @Test + public void afailedWriteChangesNothingAndSaysSo() { + this.icloud = FakeICloudService.withTags() + .whereRenamingFails(ICloudFailure.MEMBERSHIP_UNUSABLE); + AppDependencies.replaceICloud(() -> this.icloud); + AppDependencies.replaceHardwareDescriber(new Describer(false)); + + final Intent intent = new Intent( + getInstrumentation().getTargetContext(), DeviceInfoActivity.class); + intent.putExtra("beaconId", AN_ACCESSORY); + this.scenario = ActivityScenario.launch(intent); + Eventually.check(() -> onView(withId(R.id.device_settings_name)) + .check(matches(isDisplayed()))); + + this.renameTo(THE_NEW_NAME); + + Eventually.check(() -> onView(withText(R.string.rename_failed_title)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + onView(withText(R.string.ok)).inRoot(isDialog()).perform(click()); + + assertTrue("a failed rename must not change the stored name", + this.storedNameFor(AN_ACCESSORY).contains(THE_OLD_NAME)); + assertNull("a failed rename must not leave a nickname behind either", + this.db.userBeaconOptionsDao().getById(AN_ACCESSORY)); + } +} 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 new file mode 100644 index 00000000..6b4e540c --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/TheDeviceListNoticesNewLocationsTest.java @@ -0,0 +1,209 @@ +package dev.wander.android.opentagviewer.ui.mydevices; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.not; + +import android.content.Context; + +import androidx.lifecycle.Lifecycle; +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.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.Eventually; +import dev.wander.android.opentagviewer.MyDevicesListActivity; +import dev.wander.android.opentagviewer.R; +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.Import; +import dev.wander.android.opentagviewer.db.room.entity.LocationReport; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; + +/** + * The device list noticing locations that arrived while it was in the background. + * + *

Reported by @parawanderer, and the interesting part is that nothing was lost. A tag + * read "No last location known"; opening its history and paging back a few days found locations + * perfectly well; going back to the list still said "No last location known". Reaching the same + * list a different way - through the map - showed "Last Updated: 3 days ago". The reports had + * been in the database the whole time. + * + *

The list loaded its locations once, in {@code onCreate}, and refreshed them afterwards only + * when the device page reported a removal or a rename. Fetching history is neither, so the + * screen had no reason to look again - and a stale screen is indistinguishable from missing data + * to the person reading it. Which is why this is worth a test: the failure mode is a correct + * database and a wrong screen, and no amount of testing the repository would find it. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class TheDeviceListNoticesNewLocationsTest { + + private static final String A_TEST_USER = "device-list-refresh@example.com"; + private static final String THE_TAG = "a-wallet"; + private static final String THE_NAME = "Shane's Wallet"; + + private static final String A_PLIST = "" + + "" + + "batteryLevel1" + + "model" + + "pairingDate2025-02-27T20:03:32Z" + + "privateKeykey" + + "databm90LWEtcmVhbC1rZXk=" + + "productId21760" + + "stableIdentifier2001~#0~#A0" + + "systemVersion2.0.73" + + "vendorId76" + + ""; + + private OpenTagViewerDatabase db; + private ActivityScenario scenario; + + @Before + public void seedAtagWithNoLocationsYet() { + final Context context = getInstrumentation().getTargetContext(); + this.db = OpenTagViewerDatabase.getInstance(context); + this.forgetIt(); + + final long importId = this.db.importDao().insert(Import.builder() + .version("0.0.2").importedAt(1_700_000_000_000L).exportedAt(1_699_000_000_000L) + .sourceUser(A_TEST_USER).exportedVia("OpenTagViewer.wizard:test").build()); + + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(THE_TAG).importId(importId).content(A_PLIST).version("0.0.2") + .fromAccount(false).isRemoved(false) + .build()); + + this.db.beaconNamingRecordDao().insertAll(BeaconNamingRecord.builder() + .id(THE_TAG).importId(importId).version("0.0.2").isRemoved(false) + .content("" + + "identifier" + THE_TAG + "" + + "name" + THE_NAME + "" + + "") + .build()); + } + + @After + public void putEverythingBack() { + if (this.scenario != null) { + this.scenario.close(); + } + this.forgetIt(); + } + + private void forgetIt() { + this.db.ownedBeaconDao().delete(OwnedBeacon.builder().id(THE_TAG).build()); + this.db.beaconNamingRecordDao().delete(BeaconNamingRecord.builder().id(THE_TAG).build()); + for (final Import stale : this.db.importDao().getImportsFromUser(A_TEST_USER)) { + this.db.importDao().delete(stale); + } + } + + /** What paging back through the history screen leaves behind: rows in LocationReport. */ + private void givenHistoryWasFetchedWhileTheListWasAway() { + this.db.locationReportDao().insertAll(LocationReport.builder() + .hashId("a-found-report") + .beaconId(THE_TAG) + .publishedAt(System.currentTimeMillis() - 3 * 24 * 60 * 60 * 1000L) + .description("Wi-Fi") + .timestamp(System.currentTimeMillis() - 3 * 24 * 60 * 60 * 1000L) + .confidence(0) + .latitude(52.370216) + .longitude(4.895168) + .horizontalAccuracy(83) + .status(144) + .lastUpdate(System.currentTimeMillis()) + .build()); + } + + private void assertTheRowSays(final int expected) { + final String text = getInstrumentation().getTargetContext().getString(expected); + + onView(allOf(withId(R.id.device_item_container), hasDescendant(withText(THE_NAME)))) + .check(matches(hasDescendant(withText(text)))); + } + + private void assertTheRowDoesNotSay(final int unwanted) { + final String text = getInstrumentation().getTargetContext().getString(unwanted); + + onView(allOf(withId(R.id.device_item_container), hasDescendant(withText(THE_NAME)))) + .check(matches(not(hasDescendant(withText(text))))); + } + + /** With nothing stored, the generic line is correct. */ + @Test + public void atagWithNoLocationsSaysSo() { + this.scenario = ActivityScenario.launch(MyDevicesListActivity.class); + + Eventually.check(() -> onView(withText(THE_NAME)).check(matches(isDisplayed()))); + this.assertTheRowSays(R.string.no_last_location_known); + } + + /** + * The reported bug. + * + *

Locations land while this screen is in the background - which is what the history + * screen does - and coming back to it has to show them. Driven by backgrounding and + * resuming the activity rather than by launching the history screen, because the mechanism + * is "this screen was away and something changed underneath it", and history is only one of + * several ways that happens: a background account read is another. + */ + @Test + public void locationsFoundWhileAwayShowUpOnReturning() { + this.scenario = ActivityScenario.launch(MyDevicesListActivity.class); + + Eventually.check(() -> onView(withText(THE_NAME)).check(matches(isDisplayed()))); + this.assertTheRowSays(R.string.no_last_location_known); + + this.givenHistoryWasFetchedWhileTheListWasAway(); + + // Away, and back - exactly what opening the tag page and pressing back does. + this.scenario.moveToState(Lifecycle.State.CREATED); + this.scenario.moveToState(Lifecycle.State.RESUMED); + + Eventually.check(() -> this.assertTheRowDoesNotSay(R.string.no_last_location_known)); + } + + /** + * And the row says something specific, not merely something different. + * + *

Guards against a fix that clears the line without filling it in - an empty subtitle + * would satisfy the assertion above and still tell the user nothing. + */ + @Test + public void thereturningRowNamesWhenItWasLastSeen() { + this.scenario = ActivityScenario.launch(MyDevicesListActivity.class); + Eventually.check(() -> onView(withText(THE_NAME)).check(matches(isDisplayed()))); + + this.givenHistoryWasFetchedWhileTheListWasAway(); + + this.scenario.moveToState(Lifecycle.State.CREATED); + this.scenario.moveToState(Lifecycle.State.RESUMED); + + // "3 days ago", however this locale phrases it, inside the relative-time sentence. + Eventually.check(() -> onView(allOf( + withId(R.id.device_item_container), hasDescendant(withText(THE_NAME)))) + .check(matches(hasDescendant(allOf( + withId(R.id.list_item_last_update), + withText(containsDays())))))); + } + + private static org.hamcrest.Matcher containsThe(final String what) { + return org.hamcrest.Matchers.containsString(what); + } + + private static org.hamcrest.Matcher containsDays() { + return containsThe("day"); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/TheMapIsToldWhatChangedTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/TheMapIsToldWhatChangedTest.java new file mode 100644 index 00000000..83be964a --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/TheMapIsToldWhatChangedTest.java @@ -0,0 +1,156 @@ +package dev.wander.android.opentagviewer.ui.mydevices; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.app.Activity; +import android.content.Context; +import android.view.View; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.espresso.Espresso; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.Eventually; +import dev.wander.android.opentagviewer.MyDevicesListActivity; +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.db.AccountBeaconsForTests; +import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; +import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.icloud.FakeICloudService; +import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; + +/** + * Telling the map that the device list changed. + * + *

The map is not rebuilt when it is returned to. It reads the beacons once and keeps + * them, so anything that adds tags has to say so on the way out - {@code isDeviceListChanged} in + * the result - and the map re-reads when it sees it. Nothing crashes when that flag is wrong; the + * tags are in the database, they are on the device list, and the map simply goes on showing what + * it had. @parawanderer imported a real account and found an empty map. + * + *

The flag was destroyed by the thing that made it true. Finishing an account read sets + * it and then calls {@code recreate()} so the new tags appear - and {@code recreate()} builds a + * new activity, where a plain field is false again. So the failure needed the whole sequence: + * import, rebuild, leave. Any test that set the flag and left without the rebuild in between + * would have passed. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class TheMapIsToldWhatChangedTest { + + private static final String PASSCODE = "123456"; + + private FakeICloudService icloud; + private KeychainMembershipRepository memberships; + private ActivityScenario scenario; + + @Before + public void startFromNothingImported() { + final Context context = getInstrumentation().getTargetContext(); + + this.memberships = new KeychainMembershipRepository( + UserAuthDataStore.getInstance(context), new AppCryptographyUtil()); + this.memberships.forget().blockingAwait(); + AccountBeaconsForTests.forgetThemAll(); + + this.icloud = FakeICloudService.withTags(); + AppDependencies.replaceICloud(() -> this.icloud); + } + + @After + public void putEverythingBack() { + if (this.scenario != null) { + this.scenario.close(); + } + AppDependencies.reset(); + AccountBeaconsForTests.forgetThemAll(); + this.memberships.forget().blockingAwait(); + } + + private boolean isShown(final int id) { + final boolean[] shown = {false}; + this.scenario.onActivity(activity -> { + final View found = activity.findViewById(id); + shown[0] = found != null && found.getVisibility() == View.VISIBLE; + }); + return shown[0]; + } + + /** + * Import from the account, then leave: the map is told. + */ + @Test + public void animportFromTheAccountSurvivesTheListRebuildingItself() { + this.scenario = ActivityScenario.launchActivityForResult(MyDevicesListActivity.class); + + Eventually.check(() -> onView(withId(R.id.my_devices_empty_fetch_button)) + .check(matches(isDisplayed()))); + onView(withId(R.id.my_devices_empty_fetch_button)).perform(click()); + + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withText(containsString( + FakeICloudService.AN_IPHONE.getSerial()))).perform(click())); + + onView(withId(R.id.icloud_passcode_input)).perform(replaceText(PASSCODE)); + Eventually.perform("unlock", () -> this.icloud.timesCalled("fetch") > 0, + () -> onView(withId(R.id.icloud_primary_button)).perform(click())); + + Eventually.check(() -> onView(withId(R.id.icloud_results_container)) + .check(matches(isDisplayed()))); + onView(withId(R.id.icloud_primary_button)).perform(click()); + + // The list rebuilds itself here - which is what used to lose the flag. + Eventually.check(() -> onView(withId(R.id.my_devices_list)) + .check(matches(isDisplayed()))); + + Espresso.pressBackUnconditionally(); + + Eventually.check(() -> assertEquals("the screen should have finished", + Activity.RESULT_OK, this.scenario.getResult().getResultCode())); + assertTrue("the map was not told the device list changed, so it will keep showing" + + " whatever it already had", + this.scenario.getResult().getResultData() + .getBooleanExtra("isDeviceListChanged", false)); + } + + /** + * And leaving without changing anything does not ask the map to re-read. + * + *

Worth pinning: the cheap way to make the test above pass is to send the flag every time, + * which turns every visit to this screen into a full re-read and a round of location fetches + * on the map. + */ + @Test + public void leavingWithoutImportingAnythingSaysNothingChanged() { + this.scenario = ActivityScenario.launchActivityForResult(MyDevicesListActivity.class); + + Eventually.check(() -> onView(withId(R.id.my_devices_empty_fetch_button)) + .check(matches(isDisplayed()))); + + Espresso.pressBackUnconditionally(); + + Eventually.check(() -> assertEquals( + Activity.RESULT_OK, this.scenario.getResult().getResultCode())); + assertTrue("nothing changed, so the map should not be asked to re-read", + !this.scenario.getResult().getResultData() + .getBooleanExtra("isDeviceListChanged", false)); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/VendorLookupHintTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/VendorLookupHintTest.java new file mode 100644 index 00000000..ec402c89 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/VendorLookupHintTest.java @@ -0,0 +1,211 @@ +package dev.wander.android.opentagviewer.ui.mydevices; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; + +import android.content.Context; +import android.content.Intent; + +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.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.DeviceInfoActivity; +import dev.wander.android.opentagviewer.Eventually; +import dev.wander.android.opentagviewer.R; +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.Import; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.HardwareDescriber; + +/** + * Telling somebody what the hex on the Type row is. + * + *

When nothing recognises an accessory, Type reads something like + * {@code vendor 0x0ABC product 0x1234}. Those are real Bluetooth SIG registry values and the + * question they answer is one browser search away - but only for a reader who knows the number + * means something, and until now the app knew that and did not say it. + * {@code where_to_look_up} has been implemented in Python and reachable from Java for as long as + * the heuristic has existed, with no screen calling it. + * + *

The hint has to stay off for everything else. An AirTag, a Chipolo, an iPad - all + * recognised, nothing to explain, and a line of registry trivia under every one of them would be + * noise on the screen people actually look at. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class VendorLookupHintTest { + + private static final String A_TAG = "test-unrecognised-tag"; + private static final String A_TEST_USER = "vendorlookuphinttest@example.invalid"; + + /** Vendor 0x0ABC is in no table anywhere, which is the point. */ + private static final int AN_UNKNOWN_VENDOR = 0x0ABC; + + private static final String A_PLIST = "" + + "" + + "batteryLevel1" + + "model" + + "pairingDate2025-02-27T20:03:32Z" + + "privateKeykey" + + "databm90LWEtcmVhbC1rZXk=" + + "productId4660" + + "stableIdentifier2001~#0~#A0" + + "systemVersion1.0" + + "vendorId" + AN_UNKNOWN_VENDOR + "" + + ""; + + private OpenTagViewerDatabase db; + private ActivityScenario scenario; + + /** Answers whatever the test needs, without a Python interpreter. */ + private static final class Describer implements HardwareDescriber { + private final String description; + private final String lookup; + + Describer(final String description, final String lookup) { + this.description = description; + this.lookup = lookup; + } + + @Override + public String describe(final String plistXml) { + return this.description; + } + + @Override + public String whereToLookUp(final String plistXml) { + return this.lookup; + } + + /** Not what this class is about; an accessory, so nothing here offers to write. */ + @Override + public Boolean isOwnDevice(final String plistXml) { + return Boolean.FALSE; + } + } + + @Before + public void seedOneUnrecognisedTag() { + final Context context = getInstrumentation().getTargetContext(); + this.db = OpenTagViewerDatabase.getInstance(context); + + this.forgetIt(); + + final long importId = this.db.importDao().insert(Import.builder() + .version("0.0.2") + .importedAt(1_700_000_000_000L) + .exportedAt(1_699_000_000_000L) + .sourceUser(A_TEST_USER) + .exportedVia("OpenTagViewer.wizard:test") + .build()); + + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(A_TAG) + .importId(importId) + .content(A_PLIST) + .version("0.0.2") + .fromAccount(false) + .isRemoved(false) + .build()); + + this.db.beaconNamingRecordDao().insertAll(BeaconNamingRecord.builder() + .id(A_TAG) + .importId(importId) + .version("0.0.2") + .isRemoved(false) + .content("" + + "identifier" + A_TAG + "" + + "nameSomething Unrecognised" + + "") + .build()); + } + + @After + public void putEverythingBack() { + if (this.scenario != null) { + this.scenario.close(); + } + AppDependencies.reset(); + this.forgetIt(); + } + + private void forgetIt() { + this.db.ownedBeaconDao().delete(OwnedBeacon.builder().id(A_TAG).build()); + this.db.beaconNamingRecordDao().delete(BeaconNamingRecord.builder().id(A_TAG).build()); + for (final Import stale : this.db.importDao().getImportsFromUser(A_TEST_USER)) { + this.db.importDao().delete(stale); + } + } + + private void open(final String description, final String lookup) { + AppDependencies.replaceHardwareDescriber(new Describer(description, lookup)); + + final Intent intent = new Intent( + getInstrumentation().getTargetContext(), DeviceInfoActivity.class); + intent.putExtra("beaconId", A_TAG); + this.scenario = ActivityScenario.launch(intent); + } + + /** The one this exists for. */ + @Test + public void anunrecognisedAccessoryIsToldWhatItsVendorNumberIs() { + this.open("vendor 0x0ABC product 0x1234", "something to look up"); + + Eventually.check(() -> onView(withId(R.id.device_type_lookup_hint)) + .check(matches(isDisplayed()))); + } + + /** + * And the number in the sentence is this accessory's, not a placeholder. + * + *

Formatted in the app rather than taken from Python's sentence, so this is also what + * pins that the two agree about which vendor is being talked about. + */ + @Test + public void thehintNamesTheVendorFromTheRecord() { + this.open("vendor 0x0ABC product 0x1234", "something to look up"); + + Eventually.check(() -> onView(withId(R.id.device_type_lookup_hint)) + .check(matches(withText(containsString("0x0ABC"))))); + } + + /** + * Silent for everything the heuristic can name, which is nearly every tag. + * + *

Python returns null the moment the vendor is in its table, so a Chipolo or an AirTag + * reaches here with nothing to say. If this ever fails, every recognised tag in the app has + * grown a line of registry trivia under it. + */ + @Test + public void arecognisedAccessorySaysNothingAboutRegistries() { + this.open("Chipolo tag", null); + + Eventually.check(() -> onView(withId(R.id.device_settings_device_type)) + .check(matches(isDisplayed()))); + onView(withId(R.id.device_type_lookup_hint)).check(matches(not(isDisplayed()))); + } + + /** And a heuristic that answered nothing at all still does not invent a hint. */ + @Test + public void aheuristicThatKnowsNothingShowsNoHint() { + this.open(null, null); + + Eventually.check(() -> onView(withId(R.id.device_settings_device_type)) + .check(matches(isDisplayed()))); + onView(withId(R.id.device_type_lookup_hint)).check(matches(not(isDisplayed()))); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/FetchFromAccountSettingTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/FetchFromAccountSettingTest.java new file mode 100644 index 00000000..27da8af6 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/FetchFromAccountSettingTest.java @@ -0,0 +1,146 @@ +package dev.wander.android.opentagviewer.ui.settings; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.intent.Intents.intended; +import static androidx.test.espresso.intent.Intents.intending; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent; +import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.allOf; + +import android.app.Activity; +import android.app.Instrumentation.ActivityResult; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.espresso.intent.Intents; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.Eventually; +import dev.wander.android.opentagviewer.FetchFromICloudActivity; +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.SettingsActivity; +import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; +import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; +import dev.wander.android.opentagviewer.python.icloud.KeychainMembership; +import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; + +/** + * Reading the Apple account from Settings. + * + *

The third door to one screen, and the one people go looking for. The empty state's + * button disappears the moment anything is imported, and the device list's overflow menu is only + * obvious to somebody who already knows it is there - so "how do I get my tags again" ends up + * being asked of Settings, which is where somebody looks when a thing is not where they expected. + * + *

What is checked is that the row exists, says what it does, and reaches the screen. Where it + * goes next is {@code FetchFromICloudFlowTest}'s business; the fetch screen itself is answered at + * the door here, because it wants an Apple session and a Python interpreter and none of that is + * what this is about. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class FetchFromAccountSettingTest { + + private ActivityScenario scenario; + private KeychainMembershipRepository memberships; + + @Before + public void answerTheFetchScreenAtTheDoor() { + this.memberships = new KeychainMembershipRepository( + UserAuthDataStore.getInstance(getInstrumentation().getTargetContext()), + new AppCryptographyUtil()); + this.memberships.forget().blockingAwait(); + + Intents.init(); + intending(hasComponent(FetchFromICloudActivity.class.getName())) + .respondWith(new ActivityResult(Activity.RESULT_CANCELED, null)); + } + + @After + public void closeIt() { + if (this.scenario != null) { + this.scenario.close(); + } + Intents.release(); + this.memberships.forget().blockingAwait(); + } + + private void openSettings() { + this.scenario = ActivityScenario.launch(SettingsActivity.class); + } + + /** As if the app had already joined the account's keychain. */ + private void givenTheAccountIsAlreadyLinked() { + this.memberships.store(new KeychainMembership( + "{\"peer_id\":\"peer-ours\"}", "ZW50cm9weQ==", "a-generated-passcode", + "a-label", 2)).blockingAwait(); + } + + @Test + public void settingsOffersToReadTheAppleAccount() { + this.openSettings(); + + Eventually.check(() -> onView(withId(R.id.settings_fetch_from_account)) + .check(matches(isDisplayed()))); + } + + /** + * And it says so once the account is linked. + * + *

The row read the same either way, which is wrong twice: somebody who has linked cannot + * tell that they have, and somebody who has not is told their tags will "update" when nothing + * has ever been read. Being a member is what makes a later read cost one tap and no device + * passcode, so it is worth saying out loud. + */ + @Test + public void alinkedAccountIsDescribedAsLinked() { + this.givenTheAccountIsAlreadyLinked(); + + this.openSettings(); + + Eventually.check(() -> onView(allOf( + withText(R.string.icloud_fetch_from_settings_linked), + isDescendantOfA(withId(R.id.settings_fetch_from_account)))) + .check(matches(isDisplayed()))); + } + + /** + * And it says what it does, not just what it is called. + * + *

"Fetch my tags from my Apple account" alone leaves somebody who already has tags + * wondering whether this would duplicate them. The subtitle is the answer. + */ + @Test + public void therowSaysWhatItWillDoToTagsAlreadyHere() { + this.openSettings(); + + Eventually.check(() -> onView(allOf( + withText(R.string.icloud_fetch_from_settings_subtitle), + isDescendantOfA(withId(R.id.settings_fetch_from_account)))) + .check(matches(isDisplayed()))); + } + + /** And tapping it gets there. */ + @Test + public void tappingItReachesTheAccountScreen() { + this.openSettings(); + + Eventually.check(() -> onView(withId(R.id.settings_fetch_from_account)) + .check(matches(isDisplayed()))); + + onView(withId(R.id.settings_fetch_from_account)).perform(click()); + + Eventually.check(() -> intended(hasComponent(FetchFromICloudActivity.class.getName()))); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/UnlinkTheAccountSettingTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/UnlinkTheAccountSettingTest.java new file mode 100644 index 00000000..8a602260 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/UnlinkTheAccountSettingTest.java @@ -0,0 +1,248 @@ +package dev.wander.android.opentagviewer.ui.settings; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.RootMatchers.isDialog; +import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +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.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.Eventually; +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.SettingsActivity; +import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; +import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; +import dev.wander.android.opentagviewer.python.icloud.KeychainMembership; +import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; + +/** + * Letting go of the Apple account again. + * + *

Linking was a one-way door until now: the Settings row went on offering to read the account + * whether or not it had already been linked, and there was no way to stop the background read + * short of clearing the app's data - which also destroys every imported tag. + * + *

What "unlink" means here is narrower than it sounds, and the tests hold the wording to + * it. Nothing in this app can leave the account's trust circle, so the peer it joined as + * goes on existing and stays visible in the user's Apple device list. All this does is forget + * the keys for it. Somebody who unlinks expecting the device list to tidy itself up will go + * looking for a bug, so the dialog has to say so before anything happens - and it is the kind of + * caveat that gets trimmed later by somebody shortening a wordy dialog. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class UnlinkTheAccountSettingTest { + + private ActivityScenario scenario; + private KeychainMembershipRepository memberships; + + @Before + public void startUnlinked() { + this.memberships = new KeychainMembershipRepository( + UserAuthDataStore.getInstance(getInstrumentation().getTargetContext()), + new AppCryptographyUtil()); + this.memberships.forget().blockingAwait(); + } + + @After + public void closeIt() { + if (this.scenario != null) { + this.scenario.close(); + } + this.memberships.forget().blockingAwait(); + } + + private void openSettings() { + this.scenario = ActivityScenario.launch(SettingsActivity.class); + } + + private void givenTheAccountIsAlreadyLinked() { + this.memberships.store(new KeychainMembership( + "{\"peer_id\":\"peer-ours\"}", "ZW50cm9weQ==", "a-generated-passcode", + "a-label", 2)).blockingAwait(); + } + + private boolean stillLinked() { + return this.memberships.get().blockingFirst().isPresent(); + } + + /** + * Hidden until there is something to unlink. + * + *

Not disabled: a permanently greyed row in Settings reads as a feature that is broken + * rather than one that does not apply yet, and this row does not apply at all before linking. + * + *

Asserted as not displayed rather than as a missing view. The row is in the layout the + * whole time - {@code withId} matches a GONE view perfectly well - so expecting + * {@code NoMatchingViewException} would pass for the wrong reason. + */ + @Test + public void thereIsNothingToUnlinkBeforeLinking() { + this.openSettings(); + + Eventually.check(() -> onView(withId(R.id.settings_fetch_from_account)) + .check(matches(isDisplayed()))); + + onView(withId(R.id.settings_unlink_account)).check(matches(not(isDisplayed()))); + } + + /** Once linked, the row appears. */ + @Test + public void alinkedAccountCanBeUnlinked() { + this.givenTheAccountIsAlreadyLinked(); + + this.openSettings(); + + Eventually.check(() -> onView(allOf( + withText(R.string.icloud_unlink_title), + isDescendantOfA(withId(R.id.settings_unlink_account)))) + .check(matches(isDisplayed()))); + } + + /** + * Tapping it asks first, and says what will survive. + * + *

Linking again is not free - it needs the Apple device passcode, which is not something + * people have to hand - so this is not a thing to do by accident. + */ + @Test + public void tappingItAsksBeforeDoingAnything() { + this.givenTheAccountIsAlreadyLinked(); + this.openSettings(); + + this.openTheUnlinkDialog(); + + Eventually.check(() -> onView(withText(R.string.icloud_unlink_confirm_title)) + .inRoot(isDialog()) + .check(matches(isDisplayed()))); + + assertTrue("asking must not have unlinked anything yet", this.stillLinked()); + } + + /** + * The dialog says the Apple device list is not tidied up by this. + * + *

The one sentence in it that stops a support question. Held by a test because it is + * exactly the sort of caveat that gets trimmed by somebody shortening a long dialog. + */ + @Test + public void thedialogSaysTheAppleDeviceEntryStays() { + this.givenTheAccountIsAlreadyLinked(); + this.openSettings(); + + this.openTheUnlinkDialog(); + + final String message = getInstrumentation().getTargetContext() + .getString(R.string.icloud_unlink_confirm_message); + + assertTrue("the dialog no longer explains that this does not remove the app from the" + + " Apple account: " + message, + message.contains("Find My")); + } + + /** Backing out of the dialog changes nothing. */ + @Test + public void cancellingLeavesTheAccountLinked() { + this.givenTheAccountIsAlreadyLinked(); + this.openSettings(); + + this.openTheUnlinkDialog(); + + onView(withText(R.string.cancel)).inRoot(isDialog()).perform(click()); + + assertTrue("cancelling unlinked the account anyway", this.stillLinked()); + onView(withId(R.id.settings_unlink_account)).check(matches(isDisplayed())); + } + + /** + * Confirming forgets the membership, and the screen catches up. + * + *

Both halves matter. Forgetting without updating the rows leaves Settings saying the + * account is linked when it is not, and the user tapping Unlink again on something already + * gone. + */ + @Test + public void confirmingUnlinksAndTheRowsFollow() { + this.givenTheAccountIsAlreadyLinked(); + this.openSettings(); + + this.openTheUnlinkDialog(); + + onView(withText(R.string.icloud_unlink_confirm_button)) + .inRoot(isDialog()).perform(click()); + Eventually.check(() -> assertFalse(this.stillLinked())); + + assertFalse("the membership was not forgotten", this.stillLinked()); + + Eventually.check(() -> onView(withId(R.id.settings_unlink_account)) + .check(matches(not(isDisplayed())))); + + Eventually.check(() -> onView(allOf( + withText(R.string.icloud_fetch_from_settings_subtitle), + isDescendantOfA(withId(R.id.settings_fetch_from_account)))) + .check(matches(isDisplayed()))); + } + + /** + * And it stays unlinked across a restart. + * + *

The bug this whole row exists next to: the linked state is read from an encrypted + * DataStore on every start, so a change that only updated the screen would look right until + * the app was reopened. + */ + @Test + public void itstaysUnlinkedWhenSettingsIsOpenedAgain() { + this.givenTheAccountIsAlreadyLinked(); + this.openSettings(); + + this.openTheUnlinkDialog(); + onView(withText(R.string.icloud_unlink_confirm_button)) + .inRoot(isDialog()).perform(click()); + Eventually.check(() -> assertFalse(this.stillLinked())); + + this.scenario.close(); + this.openSettings(); + + Eventually.check(() -> onView(withId(R.id.settings_fetch_from_account)) + .check(matches(isDisplayed()))); + onView(withId(R.id.settings_unlink_account)).check(matches(not(isDisplayed()))); + } + + /** + * Tap the row and wait for the dialog. + * + *

A plain click, not {@link Eventually#perform}. `perform` is for an action that + * might finish the flow it is driving, and it asks its predicate before each attempt - + * so a predicate phrased as "is the dialog up yet" runs {@code inRoot(isDialog())} against a + * screen with no dialog on it, and Espresso's root picker retries internally until it times + * out before reporting that. Seven tests took six and a half minutes that way. Opening a + * dialog cannot tear the activity down, so waiting for the row and clicking it is both + * correct and roughly instant. + */ + private void openTheUnlinkDialog() { + Eventually.check(() -> onView(withId(R.id.settings_unlink_account)) + .check(matches(isDisplayed()))); + + onView(withId(R.id.settings_unlink_account)).perform(click()); + + Eventually.check(() -> onView(withText(R.string.icloud_unlink_confirm_title)) + .inRoot(isDialog()) + .check(matches(isDisplayed()))); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/AccountTagSourceTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/AccountTagSourceTest.java new file mode 100644 index 00000000..6f87fe2e --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/AccountTagSourceTest.java @@ -0,0 +1,115 @@ +package dev.wander.android.opentagviewer.util.parse; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +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; + +/** + * Where a tag came from has to survive the trip to the screen. + * + *

This is a plumbing test for a permission decision. {@code from_account} is stored on + * the row, but every screen works from {@link BeaconInformation}, and the parser rebuilds that + * object field by field - so a flag that is not copied across arrives as {@code false} and reads + * as "this is the app's own copy, go ahead and delete it". Nothing throws when that happens. The + * user removes an account tag, the row is marked removed, and the next refresh writes it back + * with no explanation. + * + *

Both construction paths are covered because there are two: an Apple-paired tag is built by + * {@link BeaconDataParser} out of its plists, and a generated one by {@code CustomAccessoryParser} + * out of the accessory JSON, with no plist to read at all. Only one of them would have been + * noticed by hand. + */ +@RunWith(AndroidJUnit4.class) +public class AccountTagSourceTest { + + /** + * Enough of an {@code OwnedBeacons} plist for every XPath in the parser to find something. + * + *

Written out rather than loaded from the fixtures under {@code src/test/resources}, + * because those carry a DOCTYPE pointing at apple.com and this needs no network to run. + */ + private static final String A_PLIST = "" + + "" + + "batteryLevel1" + + "identifiera-tag" + + "model" + + "pairingDate2025-02-27T20:03:32Z" + + "privateKeykey" + + "databm90LWEtcmVhbC1rZXk=" + + "productId21760" + + "stableIdentifier2001~#0~#A0" + + "systemVersion2.0.73" + + "vendorId76" + + ""; + + private static final String A_MAPPING = + "{\"type\":\"custom_rolling_key_accessory\",\"name\":\"Homemade\"," + + "\"private_keys\":[\"aaa\",\"bbb\"]}"; + + private static BeaconInformation parseOne(final OwnedBeacon row) { + final List parsed = + BeaconDataParser.parse(List.of(new BeaconData(row.id, row, null, null))); + return parsed.get(0); + } + + private static OwnedBeacon appleTag(final boolean fromAccount) { + return OwnedBeacon.builder() + .id("a-tag") + .content(A_PLIST) + .version(fromAccount ? "account" : "0.0.2") + .fromAccount(fromAccount) + .isRemoved(false) + .build(); + } + + private static OwnedBeacon generatedTag(final boolean fromAccount) { + return OwnedBeacon.builder() + .id("a-generated-tag") + // No plist at all - that absence is what makes it one of these. + .content(null) + .accessoryJson(A_MAPPING) + .version(fromAccount ? "account" : "0.0.3") + .fromAccount(fromAccount) + .isRemoved(false) + .build(); + } + + @Test + public void atagReadFromTheAccountSaysSo() { + assertTrue("a tag read from the Apple account must arrive on screen marked as one", + parseOne(appleTag(true)).isFromAccount()); + } + + @Test + public void atagImportedFromAFileDoesNot() { + assertFalse("a file-imported tag must not be mistaken for an account one", + parseOne(appleTag(false)).isFromAccount()); + } + + /** + * The path with no plist to read. + * + *

A generated tag arrives in a bundle today, so this is the answer that matters - but it + * is read from the row rather than assumed, and the next test is why. + */ + @Test + public void agenerateTagFromAFileDoesNotClaimToBeFromTheAccount() { + assertFalse(parseOne(generatedTag(false)).isFromAccount()); + } + + @Test + public void agenerateTagCarriesTheFlagItWasStoredWith() { + assertTrue("the custom-accessory path must read the flag, not hardcode it", + parseOne(generatedTag(true)).isFromAccount()); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/BatteryLevelDescriptionTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/BatteryLevelDescriptionTest.java new file mode 100644 index 00000000..b43c1518 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/BatteryLevelDescriptionTest.java @@ -0,0 +1,98 @@ +package dev.wander.android.opentagviewer.util.parse; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.content.Context; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.HashSet; +import java.util.Set; + +/** + * Turning the raw {@code batteryLevel} integer into something readable. + * + *

Apple documents none of this, so the labels are a community reading rather than a + * specification - which is exactly why the raw number is kept alongside them, and why the tests + * below care more about "an unknown value is not relabelled" than about any particular word. + */ +@RunWith(AndroidJUnit4.class) +public class BatteryLevelDescriptionTest { + + private Context context() { + return getInstrumentation().getTargetContext(); + } + + /** The number comes first, because that is what a bug report quotes. */ + @Test + public void thenumberIsShownBeforeItsMeaning() { + final String described = BatteryLevelDescription.describe( + this.context(), BatteryLevelDescription.FULL); + + assertTrue("the raw value must survive into the text: " + described, + described.startsWith("1")); + assertTrue("the meaning should be alongside it: " + described, described.length() > 1); + } + + /** + * A value nobody documented is shown bare, not guessed at. + * + *

The important one. A new state, or a field that turns out not to be a battery level on + * some accessory, must not be dressed up as something certain - a label reads as knowledge in + * a way a number does not. + */ + @Test + public void anunrecognisedValueIsLeftAsANumber() { + for (final int strange : new int[] {5, 9, 42, -1, Integer.MAX_VALUE}) { + assertEquals("a value with no documented meaning must not be relabelled", + String.valueOf(strange), + BatteryLevelDescription.describe(this.context(), strange)); + } + } + + /** The five documented values each say something, and say something different. */ + @Test + public void thedocumentedValuesAreAllDistinct() { + final Set seen = new HashSet<>(); + + for (final int level : new int[] { + BatteryLevelDescription.UNKNOWN, + BatteryLevelDescription.FULL, + BatteryLevelDescription.MEDIUM, + BatteryLevelDescription.LOW, + BatteryLevelDescription.VERY_LOW}) { + + final String described = BatteryLevelDescription.describe(this.context(), level); + + assertTrue("level " + level + " was left as a bare number", + described.length() > String.valueOf(level).length()); + assertTrue("two levels describe themselves identically: " + described, + seen.add(described)); + } + } + + /** + * Zero is "not reported yet", not "flat". + * + *

Worth its own test because getting it backwards is both easy and alarming: a tag nobody + * has walked past yet would be reported as a dead battery, and the user would go and change + * a perfectly good one. + */ + @Test + public void zeroIsNotKnownRatherThanEmpty() { + final String described = BatteryLevelDescription.describe( + this.context(), BatteryLevelDescription.UNKNOWN); + + assertEquals(this.context().getString( + dev.wander.android.opentagviewer.R.string.battery_level_described, + 0, + this.context().getString( + dev.wander.android.opentagviewer.R.string.battery_level_unknown)), + described); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/util/rx/AccountReadPolicyTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/rx/AccountReadPolicyTest.java new file mode 100644 index 00000000..4f9e1ffa --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/rx/AccountReadPolicyTest.java @@ -0,0 +1,132 @@ +package dev.wander.android.opentagviewer.util.rx; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * When the app re-reads the Apple account on its own. + * + *

Pure bookkeeping, so it is tested by handing it clock values rather than by waiting. What it + * decides is invisible either way: reading too often queues in front of the user's own work, + * reading too rarely means a tag added in Find My never turns up, and neither throws. + */ +@RunWith(AndroidJUnit4.class) +public class AccountReadPolicyTest { + + private static final long SIX_HOURS = 6L * 60 * 60 * 1000; + private static final long NOON = 1_700_000_000_000L; + + private AccountReadPolicy aPolicy() { + return new AccountReadPolicy(SIX_HOURS); + } + + /** Nothing to read, and nothing to say about it. */ + @Test + public void anaccountThatWasNeverLinkedIsNeverRead() { + assertEquals(AccountReadPolicy.Decision.NOT_LINKED, + this.aPolicy().decide(NOON, false, false)); + } + + /** + * The first tick after linking reads. + * + *

"Never read" is not "read at the epoch": somebody who has just connected an account + * expects their tags shortly, not in six hours. + */ + @Test + public void thefirstTickAfterLinkingReads() { + assertTrue(this.aPolicy().decide(NOON, true, false).shouldRead()); + } + + @Test + public void asecondTickStraightAfterwardsDoesNot() { + final AccountReadPolicy policy = this.aPolicy(); + policy.markRead(NOON); + + assertEquals(AccountReadPolicy.Decision.TOO_SOON, + policy.decide(NOON + 1000, true, false)); + } + + @Test + public void oncetheIntervalHasPassedItReadsAgain() { + final AccountReadPolicy policy = this.aPolicy(); + policy.markRead(NOON); + + assertTrue(policy.decide(NOON + SIX_HOURS, true, false).shouldRead()); + } + + /** + * Busy wins over due, and that order is the point. + * + *

Calls into Python are serialised and a location fetch for one accessory can run for + * minutes. A read that decided to go while one was running would not wait politely - it would + * take the lock the moment that fetch released it, ahead of whatever the user did next. + */ + @Test + public void abusyInterpreterIsWaitedOutRatherThanQueuedBehind() { + final AccountReadPolicy policy = this.aPolicy(); + policy.markRead(NOON - SIX_HOURS * 2); + + assertEquals("a read that is due must still yield to work already running", + AccountReadPolicy.Decision.BUSY, policy.decide(NOON, true, true)); + } + + /** And skipping for busy does not count as having read - the next free tick still goes. */ + @Test + public void skippingBecauseOfBusyDoesNotCountAsAread() { + final AccountReadPolicy policy = this.aPolicy(); + + policy.decide(NOON, true, true); + + assertTrue("the skipped read must still be owed", policy.decide(NOON, true, false).shouldRead()); + assertFalse(policy.hasEverRead()); + } + + /** + * The time recorded is when the read started. + * + *

A read that took twenty minutes behind a queue must not immediately earn another one for + * having finished twenty minutes later. + */ + @Test + public void theintervalRunsFromWhenTheReadStarted() { + final AccountReadPolicy policy = this.aPolicy(); + final long started = NOON; + + policy.markRead(started); + + assertEquals(AccountReadPolicy.Decision.TOO_SOON, + policy.decide(started + SIX_HOURS - 1, true, false)); + assertTrue(policy.decide(started + SIX_HOURS, true, false).shouldRead()); + } + + /** + * Unlinking forgets when it last read. + * + *

Otherwise linking a different account would wait out an interval measured against the + * previous one, and the tags would not appear for hours with nothing explaining why. + */ + @Test + public void unlinkingMeansTheNextLinkReadsImmediately() { + final AccountReadPolicy policy = this.aPolicy(); + policy.markRead(NOON); + + policy.forget(); + + assertTrue(policy.decide(NOON + 1000, true, false).shouldRead()); + } + + /** Every decision says why, because this is only ever seen in a log. */ + @Test + public void everyDecisionExplainsItself() { + for (final AccountReadPolicy.Decision decision : AccountReadPolicy.Decision.values()) { + assertFalse(decision + " has nothing to say", decision.reason().isBlank()); + } + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/util/rx/ScanOrderTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/rx/ScanOrderTest.java new file mode 100644 index 00000000..165be48c --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/rx/ScanOrderTest.java @@ -0,0 +1,134 @@ +package dev.wander.android.opentagviewer.util.rx; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +/** + * The order tags are asked about in on a scheduled fetch. + * + *

Order is not cosmetic here. The fetch is one accessory at a time and can be abandoned + * half way - the user closes the app - so whatever sorts last is not merely late, it is the tag + * that is always skipped. A fixed order concentrates that cost on one unlucky tag forever. + */ +@RunWith(AndroidJUnit4.class) +public class ScanOrderTest { + + private static ScanOrder.Candidate answering(final String id) { + return new ScanOrder.Candidate(id, true, true); + } + + private static ScanOrder.Candidate silent(final String id) { + return new ScanOrder.Candidate(id, true, false); + } + + private static ScanOrder.Candidate neverScanned(final String id) { + return new ScanOrder.Candidate(id, false, false); + } + + /** Tags that answered last time come first. They are cheap and they change the screen. */ + @Test + public void tagsThatAnsweredGoBeforeTagsThatDidNot() { + final List order = ScanOrder.forScheduledFetch( + List.of(silent("quiet-1"), answering("chatty-1"), silent("quiet-2"), + answering("chatty-2")), + new Random(1)); + + assertEquals("the answering tags should be the first two", + Set.of("chatty-1", "chatty-2"), new HashSet<>(order.subList(0, 2))); + assertEquals(Set.of("quiet-1", "quiet-2"), new HashSet<>(order.subList(2, 4))); + } + + /** + * Tags nobody has scanned sit between them. + * + *

No evidence either way: they should not queue behind known-silent tags, and they have + * not earned a place ahead of tags known to be answering. + */ + @Test + public void neverScannedTagsSitBetweenTheTwo() { + final List order = ScanOrder.forScheduledFetch( + List.of(silent("quiet"), neverScanned("new"), answering("chatty")), + new Random(1)); + + assertEquals(List.of("chatty", "new", "quiet"), order); + } + + /** + * On a fresh install the whole batch is simply random. + * + *

Which is the right answer when nothing is known about anything - and it falls out of the + * bucketing rather than being a special case in the code. + */ + @Test + public void afreshInstallIsFullyShuffled() { + final List tags = new ArrayList<>(); + for (int i = 0; i < 12; i++) { + tags.add(neverScanned("tag-" + i)); + } + + final Set> seen = new HashSet<>(); + for (int seed = 0; seed < 30; seed++) { + seen.add(ScanOrder.forScheduledFetch(tags, new Random(seed))); + } + + assertTrue("a fresh install produced the same order every time", seen.size() > 1); + } + + /** + * No tag is permanently last. + * + *

The reason the shuffle exists. With a fixed order the same tag loses every interrupted + * batch, for the life of the install, through no property of its own. + */ + @Test + public void withinAgroupNoTagIsAlwaysLast() { + final List tags = List.of( + answering("a"), answering("b"), answering("c"), answering("d")); + + // **One Random across the runs, not a fresh one per seed.** Java scrambles a seed + // weakly, so the first draw from new Random(0), new Random(1), new Random(2)... is + // strongly correlated - and in Fisher-Yates the first draw picks the last element. The + // first version of this used consecutive seeds and saw the same tag last forty times, + // which said something true about java.util.Random and nothing about this code. It is + // also what production does: the repository holds one Random for the life of the app. + final Random random = new Random(20260821L); + + final Set everLast = new HashSet<>(); + for (int run = 0; run < 40; run++) { + final List order = ScanOrder.forScheduledFetch(tags, random); + everLast.add(order.get(order.size() - 1)); + } + + assertTrue("only " + everLast + " ever came last, so the rest are never starved", + everLast.size() > 1); + } + + /** Everything asked for comes back, exactly once. */ + @Test + public void everyTagIsAskedAboutAndNoneTwice() { + final List tags = List.of( + answering("a"), silent("b"), neverScanned("c"), silent("d"), answering("e")); + + final List order = ScanOrder.forScheduledFetch(tags, new Random(7)); + + assertEquals("a tag was dropped or duplicated", 5, order.size()); + assertEquals(Set.of("a", "b", "c", "d", "e"), new HashSet<>(order)); + } + + /** Nothing in, nothing out - and no exception on the way. */ + @Test + public void anemptyBatchIsAnEmptyOrder() { + assertTrue(ScanOrder.forScheduledFetch(List.of(), new Random(1)).isEmpty()); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/util/rx/WideScanBackoffTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/rx/WideScanBackoffTest.java new file mode 100644 index 00000000..e9d54df3 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/rx/WideScanBackoffTest.java @@ -0,0 +1,106 @@ +package dev.wander.android.opentagviewer.util.rx; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.TimeUnit; + +/** + * How often a silent tag is asked again. + * + *

A tag with no key alignment record searches its whole life on every attempt, at a request + * per ~290 keys - so the silent tags are the expensive ones, and asking them as often as the + * healthy ones spends most of the app's conversation with Apple on the least likely answers. + * + *

Nothing here throws when it is wrong. Too eager is a quiet flood of requests; too slow is a + * tag that has come back and goes unnoticed for days. Both are invisible without a test. + */ +@RunWith(AndroidJUnit4.class) +public class WideScanBackoffTest { + + private static final long NOON = 1_700_000_000_000L; + + /** A tag nobody has looked for yet is always worth one attempt. */ + @Test + public void atagThatWasNeverSearchedForIsDue() { + assertTrue(WideScanBackoff.isDue(NOON, 0, null)); + } + + /** And so is one that answered last time - no delay at all. */ + @Test + public void atagThatAnsweredLastTimeIsNotBackedOff() { + assertEquals(0, WideScanBackoff.waitMillisAfter(0)); + assertTrue(WideScanBackoff.isDue(NOON, 0, NOON - 1)); + } + + /** + * The first couple of silences buy nothing. + * + *

A fortnight in a drawer is a normal tag having a normal week. Backing off immediately + * would make the app slow to notice ordinary tags, which is the opposite of the point. + */ + @Test + public void oneortwoQuietFetchesAreNotTreatedAsEvidence() { + assertEquals(0, WideScanBackoff.waitMillisAfter(1)); + assertTrue(WideScanBackoff.isDue(NOON, 1, NOON - 1)); + } + + /** Then it lengthens. Each further silence costs more than the last. */ + @Test + public void thewaitGrowsWithEachFruitlessSearch() { + long previous = -1; + for (int scans = 2; scans <= 6; scans++) { + final long wait = WideScanBackoff.waitMillisAfter(scans); + assertTrue("wait did not grow at " + scans + " fruitless scans", wait > previous); + previous = wait; + } + } + + /** + * And stops growing. + * + *

A tag can always come back - a bike found, a coat out of storage - so nothing is left + * unasked for longer than a day. Unbounded growth would mean a tag that recovered after a + * long silence stayed invisible for as long as it had been quiet. + */ + @Test + public void thewaitIsCappedRatherThanGrowingForever() { + assertEquals(WideScanBackoff.longestWaitMillis(), WideScanBackoff.waitMillisAfter(7)); + assertEquals(WideScanBackoff.longestWaitMillis(), WideScanBackoff.waitMillisAfter(500)); + assertTrue("nothing should be left unasked for more than a day", + WideScanBackoff.longestWaitMillis() <= TimeUnit.DAYS.toMillis(1)); + } + + /** A tag inside its backoff is skipped. */ + @Test + public void atagSearchedTooRecentlyIsNotDue() { + final long waited = WideScanBackoff.waitMillisAfter(4); + + assertFalse(WideScanBackoff.isDue(NOON + waited - 1, 4, NOON)); + assertTrue(WideScanBackoff.isDue(NOON + waited, 4, NOON)); + } + + /** + * The backoff is time-based, so it cannot leak into a manual refresh. + * + *

Somebody who opens a tag and asks gets a search however long it has been quiet - they + * may have just found the thing. This class has no say in that: it is consulted where the + * periodic request list is built and nowhere else, and having no state of its own is what + * makes that impossible to get wrong by accident. + */ + @Test + public void ithasNoStateOfItsOwnToLeakIntoAManualRefresh() { + final long waited = WideScanBackoff.waitMillisAfter(6); + + assertFalse(WideScanBackoff.isDue(NOON, 6, NOON)); + assertEquals("asking twice must give the same answer", + waited, WideScanBackoff.waitMillisAfter(6)); + assertTrue(WideScanBackoff.isDue(NOON + waited, 6, NOON)); + } +} diff --git a/app/src/debug/python/apple_test_double.py b/app/src/debug/python/apple_test_double.py new file mode 100644 index 00000000..4843e9e8 --- /dev/null +++ b/app/src/debug/python/apple_test_double.py @@ -0,0 +1,176 @@ +""" +A signed-in Apple account that is not Apple, installed on the Python side of the bridge. + +**The other half of `icloud_test_double`, and it exists for the same reason.** Everything this +app does against Apple happens behind Python, so a fake on the Java side of the bridge skips the +bridge - and two bugs shipped through exactly that gap in the iCloud path while the whole suite +stayed green. + +**What this one unblocks is the map.** ``MapsActivity`` draws its stored locations from a stream +that is *zipped* with ``PythonAuthService.restoreAccount``, so a session that will not restore +disposes the drawing side before it emits. That is correct behaviour - somebody whose session is +unusable is sent to sign in - but it means no test could reach the drawing at all, and the app's +main screen had one thin "it starts" assertion. A restorable session is the missing piece. + +**Restoring needs no network, which is what makes this small.** ``getAccount`` calls FindMy.py's +``AppleAccount.from_json`` and nothing else; the network only appears later, at fetch time. So +what is replaced here is the deserialisation and the fetch, and everything between them - the +service, the request building, the storage, the drawing - is the shipping code. + +**Debug source set only.** Chaquopy compiles ``src//python`` alongside +``src/main/python``, so this is in the debug APK the instrumented tests run against and in no +release build. Nothing in ``main`` imports it; it is installed from a test, at runtime. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +import main + +_originals: dict[str, Any] = {} + + +class _FakeReport: + """ + One location report, in the attributes :func:`main._serializeReports` reads off a real one. + + Sortable, because that function sorts them - a real ``LocationReport`` orders by timestamp + and anything standing in for one has to as well. + """ + + def __init__( + self, + timestamp: datetime, + latitude: float, + longitude: float, + confidence: int = 2, + horizontal_accuracy: int = 83, + status: int = 144) -> None: + self.timestamp = timestamp + self.latitude = latitude + self.longitude = longitude + self.confidence = confidence + self.horizontal_accuracy = horizontal_accuracy + self.status = status + self.description = "Wi-Fi" + + def __lt__(self, other: Any) -> bool: + return self.timestamp < other.timestamp + + +class _FakeAccessory: + """ + A tag whose key window is already narrow, standing in for a `FindMyAccessory`. + + Narrow on purpose: a wide one sends the fetch down the alignment probe, which is a different + code path with its own tests. What is wanted here is the ordinary case. + """ + + def __init__(self, beaconId: str) -> None: + self._id = beaconId + + def get_min_index(self, _dt: Any) -> int: + return 0 + + def get_max_index(self, _dt: Any) -> int: + return 96 # a day of keys, at one every fifteen minutes + + def to_json(self) -> dict[str, Any]: + return {"type": "accessory", "id": self._id} + + +class _FakeAccount: + """The account object `getAccount` hands back, with the sockets left out.""" + + def __init__(self) -> None: + self.fetchedFor: list[str] = [] + + # `main` only ever reads this to decide whether a restore succeeded, and only inside the + # real getAccount - which is replaced. Present so anything else that looks is not surprised. + @property + def login_state(self) -> Any: + from findmy.reports import LoginState + + return LoginState.LOGGED_IN + + def fetch_location(self, accessory: Any) -> list[_FakeReport]: + self.fetchedFor.append(getattr(accessory, "_id", "?")) + return list(_reports) + + def fetch_location_history(self, accessory: Any) -> list[_FakeReport]: + self.fetchedFor.append(getattr(accessory, "_id", "?")) + return list(_reports) + + +#: What every fetch returns. Replaced by :func:`install`. +_reports: list[_FakeReport] = [] + +#: The account the last :func:`install` handed out, so a test can ask what was fetched. +theAccount: _FakeAccount | None = None + + +def aReportFrom(hoursAgo: float, latitude: float, longitude: float) -> _FakeReport: + """A report at a time relative to now, which is what the app's windows are measured from.""" + return _FakeReport( + timestamp=datetime.now(timezone.utc) - timedelta(hours=hoursAgo), + latitude=latitude, + longitude=longitude) + + +def install(latitude: float = 52.370216, longitude: float = 4.895168, + hoursAgo: float = 2.0) -> None: + """ + Make any stored session restore, and make every fetch return one report. + + Idempotent: installing twice keeps the first set of originals, so an uninstall still puts the + real functions back rather than a fake one. + """ + global theAccount, _reports + + theAccount = _FakeAccount() + _reports = [aReportFrom(hoursAgo, latitude, longitude)] + + if not _originals: + _originals["getAccount"] = main.getAccount + _originals["accessoryFromJson"] = main.accessoryFromJson + + def getAccount(serializedAccountData: str, anisetteServerUrl: Any = None, + localAnisette: Any = None) -> Any: + # Whatever was stored, however unparseable. The point is to get past the restore, not + # to test it - assertAnisetteIsSupported and from_json have their own tests. + return theAccount + + def accessoryFromJson(accessoryJson: str) -> Any: + # The beacon id is not in the JSON the app stores, so this cannot recover it. The tests + # that care about which tag was fetched read the request list, not this. + return _FakeAccessory(accessoryJson[:24]) + + main.getAccount = getAccount + main.accessoryFromJson = accessoryFromJson + + +def installWithNothingToReport() -> None: + """The same, but every fetch comes back empty - a tag nobody has walked past.""" + global _reports + + install() + _reports = [] + + +def uninstall() -> None: + """Put the real functions back. Safe to call without a matching install.""" + global theAccount, _reports + + for name, original in _originals.items(): + setattr(main, name, original) + + _originals.clear() + theAccount = None + _reports = [] + + +def howManyFetches() -> int: + """How many accessories were fetched for, for a test to assert on from Java.""" + return 0 if theAccount is None else len(theAccount.fetchedFor) diff --git a/app/src/debug/python/icloud_test_double.py b/app/src/debug/python/icloud_test_double.py new file mode 100644 index 00000000..b53c8169 --- /dev/null +++ b/app/src/debug/python/icloud_test_double.py @@ -0,0 +1,263 @@ +""" +An Apple account that is not Apple, installed on the Python side of the bridge. + +**Why this exists rather than another Java fake.** Every screen test replaces +:class:`ICloudService` with ``FakeICloudService``, which is right for testing screens and means +the real implementation - the one that crosses into Python, converts objects, parses JSON and maps +reason strings - never runs. A bug lived in exactly that gap: ``openFor`` checked its result with +``made.toJava(Object.class)``, which throws for any Python object, so the entire iCloud flow was +dead on every device while the suite stayed green. Everything external in this app is behind +Python, so a fake on the Java side of the bridge skips the bridge. + +**Debug source set only.** Chaquopy compiles ``src//python`` alongside +``src/main/python``, so this is in the debug APK the instrumented tests run against and in no +release build. Nothing in ``main`` imports it; it is installed from a test, at runtime. + +**What it replaces is the network, and nothing else.** The two module functions patched here are +where ``exporter.icloud`` talks to Apple. Above them, everything is the shipping code: +``ICloudSession`` drives the flow, builds the JSON, decides what each failure means - and the +``Candidate`` objects handed back are the real dataclasses, so the plists Java receives are +rendered by the same code that renders a real account's. +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from types import SimpleNamespace +from typing import Any + +from exporter import icloud + +_originals: dict[str, Any] = {} + +#: The device whose passcode unlocks the keychain, as the escrow record describes it. +A_SERIAL = "F2LX9Q" + +#: What the fake refuses, so the rejected-passcode path can be driven for real. +THE_RIGHT_PASSCODE = "123456" + + +def _keyMaterial(length: int) -> bytes: + """Bytes of the right shape. Not a key, and not pretending to be one.""" + return bytes(range(1, length + 1)) + + +def anOwnedBeaconRecord(name: str) -> dict[str, Any]: + """ + An accessory's record, in the shape :func:`to_owned_beacon_plist` produces. + + Rendered to XML by the bridge and then parsed by the app, so the field names here are the + ones the Android side reads - this is the document both sides have to agree about. + """ + return { + "batteryLevel": 1, + "identifier": name, + "model": "", + "pairingDate": datetime(2025, 2, 27, 20, 3, 32, tzinfo=timezone.utc), + "privateKey": {"key": {"data": _keyMaterial(28)}}, + "productId": 21760, + "secondarySharedSecret": {"key": {"data": _keyMaterial(32)}}, + "sharedSecret": {"key": {"data": _keyMaterial(32)}}, + "stableIdentifier": ["2001~#001234a12345aaac~#A02BCDEFG1AB"], + "systemVersion": "2.0.73", + "vendorId": 76, + } + + +def aNamingRecord(beaconId: str, name: str, emoji: str | None) -> dict[str, Any]: + record: dict[str, Any] = { + "identifier": f"naming-{beaconId}", + "associatedBeacon": beaconId, + "name": name, + } + if emoji is not None: + record["emoji"] = emoji + return record + + +class _FakeEscrowRecord: + """One recoverable device, in the attributes the bridge reads off a real record.""" + + def __init__(self, serial: str, name: str, model: str, modelClass: str) -> None: + self.serial = serial + self.device_name = name + self.device_model = model + self.device_model_class = modelClass + self.escrowed_at = datetime(2024, 3, 12, tzinfo=timezone.utc) + + def describe(self) -> str: + return f"{self.device_name}, {self.device_model}, serial {self.serial}" + + +class _FakeSession: + """The keychain session: recovering from an escrow record, and joining as a peer.""" + + def __init__(self, client: _FakeClient) -> None: + self._client = client + + async def recover(self, record: Any, passcode: str) -> Any: + self._client.unlockedWith.append((record.serial, passcode)) + + if passcode != THE_RIGHT_PASSCODE: + # The real one raises RecoveryError, which the bridge maps to a rejected passcode. + # Raised from here so that mapping is exercised rather than assumed. + from findmy.keychain.recovery import RecoveryError + + raise RecoveryError("That passcode was not accepted.") + + return SimpleNamespace(peer_id=f"peer-for-{record.serial}") + + async def join(self, peer: Any, *, passcode: str, device: Any, os_version: str) -> Any: + self._client.joinedWith = SimpleNamespace( + peer=peer, passcode=passcode, device=device, os_version=os_version) + + return SimpleNamespace( + peer=SimpleNamespace( + peer_id="peer-ours", to_json=lambda: {"peer_id": "peer-ours"}), + bottle=SimpleNamespace(entropy=bytes(72)), + label="OpenTagViewer", + shares=2) + + +class _FakeClient: + """The Find My client, with the sockets left out and the calls recorded.""" + + def __init__(self) -> None: + self.session = _FakeSession(self) + self.unlockedWith: list[tuple[str, str]] = [] + self.resumedAs: list[Any] = [] + self.renamedWith: list[tuple[str, dict[str, Any]]] = [] + self.joinedWith: Any = None + self.closed = False + + async def __aenter__(self) -> _FakeClient: + return self + + async def __aexit__(self, *_: Any) -> bool: + self.closed = True + return False + + async def recovery_options(self, *, refresh: bool = False) -> Any: + return SimpleNamespace( + recoverable=[ + _FakeEscrowRecord(A_SERIAL, "Shane’s iPhone", "iPhone15,2", "iPhone"), + _FakeEscrowRecord("C02XK", "Work MacBook", "MacBookPro18,3", "Mac"), + ], + viability_is_trustworthy=True) + + async def resume(self, peer: Any, **_: Any) -> list[Any]: + self.resumedAs.append(peer) + return [] + + async def rename(self, accessory: str, **changes: Any) -> Any: + self.renamedWith.append((accessory, changes)) + return SimpleNamespace(identifier=accessory) + + +#: The client the last :func:`install` handed out, so a test can ask what reached it. +theClient: _FakeClient | None = None + + +def _fetched() -> Any: + """ + What one account holds, as the real dataclasses. + + Real ones on purpose: the bridge turns these into the JSON Java parses, so using the shipping + types means the field names, the label fallback and the plist rendering are all the ones a + real account would go through. + """ + return icloud.Fetched( + candidates=[ + icloud.Candidate( + beacon_id="a-bike-tag", + name="Bike", + emoji="🚲", + has_alignment=True, + owned_beacon=anOwnedBeaconRecord("a-bike-tag"), + naming_record=aNamingRecord("a-bike-tag", "Bike", "🚲"), + key_alignment_record={"primaryKeyIndex": 12, "secondaryKeyIndex": 12}, + ), + # No naming record, which CloudKit really does hold for an accessory nobody named. + icloud.Candidate( + beacon_id="a-nameless-tag", + name=None, + emoji=None, + has_alignment=False, + owned_beacon=anOwnedBeaconRecord("a-nameless-tag"), + naming_record=None, + key_alignment_record=None, + ), + ], + skipped=[icloud.Skipped("an-ipad", "it is one of your own devices")], + ) + + +def install() -> None: + """ + Point ``exporter.icloud`` at the fake account, and remember what was there. + + Idempotent: installing twice keeps the first set of originals, so an uninstall still puts the + real functions back rather than a fake one. + """ + global theClient + + theClient = _FakeClient() + + if not _originals: + _originals["open_client"] = icloud.open_client + _originals["fetch"] = icloud.fetch + + async def open_client(account: Any, identity: Any = None) -> _FakeClient: + return theClient + + async def fetch(client: Any) -> Any: + return _fetched() + + icloud.open_client = open_client + icloud.fetch = fetch + + +def uninstall() -> None: + """Put the real functions back. Safe to call without a matching install.""" + global theClient + + for name, original in _originals.items(): + setattr(icloud, name, original) + + _originals.clear() + theClient = None + + +def anAccount() -> Any: + """ + An account object shaped like the one the app signs in with. + + ``openSession`` guards on the two private attributes FindMy.py's account carries, and a join + reads the identity and serial off the async half - rule 11's single source of truth, which is + why they are here rather than invented further down. + """ + return SimpleNamespace( + _asyncacc=SimpleNamespace( + serial="0PENTAGVIEWR", + identity=SimpleNamespace( + model="iPhone17,1", os_name="iPhone OS", os_version="18.1", + os_build="22B83", cfnetwork="1568.100.1", darwin="24.1.0")), + _evt_loop=asyncio.new_event_loop()) + + +def whatReachedTheAccount() -> dict[str, Any]: + """What the fake was asked to do, for a test to assert on from Java.""" + if theClient is None: + return {} + + return { + "unlockedWith": [f"{serial}:{passcode}" for serial, passcode in theClient.unlockedWith], + "resumeCount": len(theClient.resumedAs), + "renamedWith": [f"{who}:{sorted(what.items())}" for who, what in theClient.renamedWith], + "joined": theClient.joinedWith is not None, + "joinedSerial": ( + getattr(theClient.joinedWith.device, "serial", None) + if theClient.joinedWith is not None else None), + "closed": theClient.closed, + } diff --git a/app/src/debug/res/values/themes_test_only.xml b/app/src/debug/res/values/themes_test_only.xml new file mode 100644 index 00000000..0c7cf82a --- /dev/null +++ b/app/src/debug/res/values/themes_test_only.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8826f7cc..ef6ff8bd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -64,6 +64,10 @@ android:name=".MyDevicesListActivity" android:exported="false" android:theme="@style/Theme.OpenTagViewer.GenericGreyishActivity" /> + Its own alias rather than sharing the account's, because the two have different + * lifetimes. Signing out clears the session; it must not take the membership with it, or + * a peer is left on the account that this app can no longer use or clean up. + */ + public static final String KEYSTORE_ALIAS_KEYCHAIN = "keychain_membership"; } 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 293a5332..ebee488c 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java @@ -11,8 +11,10 @@ import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.os.Bundle; import android.text.format.DateFormat; +import android.text.format.DateUtils; import android.util.Log; import android.util.TypedValue; import android.view.View; @@ -20,6 +22,7 @@ import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.LinearLayout; +import android.util.Pair; import android.widget.PopupMenu; import android.widget.TextView; import android.widget.Toast; @@ -27,6 +30,7 @@ import androidx.activity.OnBackPressedCallback; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; +import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.databinding.DataBindingUtil; @@ -51,6 +55,8 @@ import dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore; import dev.wander.android.opentagviewer.db.repo.BeaconRepository; import dev.wander.android.opentagviewer.db.repo.UserDataRepository; +import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; +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.UserSettings; @@ -58,10 +64,15 @@ 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.PropertiesUtil; +import dev.wander.android.opentagviewer.util.parse.BatteryLevelDescription; import dev.wander.android.opentagviewer.util.parse.BeaconDataParser; +import dev.wander.android.opentagviewer.util.rx.WideScanBackoff; import dev.wander.android.opentagviewer.python.AppDependencies; import dev.wander.android.opentagviewer.ui.BeaconIcon; import dev.wander.android.opentagviewer.python.HardwareDescriber; +import dev.wander.android.opentagviewer.python.icloud.AccessoryRenamer; +import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.disposables.Disposable; @@ -81,7 +92,8 @@ public class DeviceInfoActivity extends AppCompatActivity { private BeaconRepository beaconRepo; private BeaconData beaconData; private BeaconInformation beaconInformation; - private @NonNull Import importData; + /** Null for a tag read from the Apple account - nothing was ever exported or imported. */ + private @Nullable Import importData; private UserSettings userSettings; private EmojiPickerView emojiPickerView; private Button currentIconButton; @@ -96,6 +108,18 @@ public class DeviceInfoActivity extends AppCompatActivity { */ private Disposable hardwareLookup; + /** + * Whether this is one of the owner's own devices, once the shared heuristic has said. + * + *

Null until it answers, and null is not false. Renaming writes to the account only + * when this is definitely false, so an unanswered question leaves the screen on the cautious + * road - a local nickname, which changes nothing anybody else can see. + */ + private Boolean isOwnDevice; + + /** The in-flight write to the account, so leaving the screen does not land on dead views. */ + private Disposable accountRename; + private boolean hasNameChanges = false; @Override @@ -122,7 +146,15 @@ protected void onCreate(Bundle savedInstanceState) { this.beaconData = this.beaconRepo.getById(this.beaconId).blockingFirst(); this.beaconInformation = BeaconDataParser.parse(List.of(this.beaconData)).get(0); - this.importData = this.beaconRepo.getImportById(this.beaconData.getOwnedBeaconInfo().importId).blockingFirst().orElseThrow(); + + // **Null for a tag read from the Apple account**, which was never exported and never + // imported - there is no bundle behind it and so no `Import` row. Fetching one anyway + // unboxes a null `importId` and crashes this screen in onCreate, which is what tapping + // an account tag used to do. + final Long importId = this.beaconData.getOwnedBeaconInfo().importId; + this.importData = importId == null + ? null + : this.beaconRepo.getImportById(importId).blockingFirst().orElse(null); binding = DataBindingUtil.setContentView(this, R.layout.activity_device_info); WindowPaddingUtil.insertUITopPadding(binding.getRoot()); @@ -136,9 +168,30 @@ protected void onCreate(Bundle savedInstanceState) { binding.setOnClickDeviceName(this::handleEditDeviceName); binding.setOnClickDeviceEmoji(this::handleEditDeviceEmoji); - binding.setExportedAt(timestampFormat.format(new Date(this.importData.exportedAt))); - binding.setImportedAt(timestampFormat.format(new Date(this.importData.importedAt))); - binding.setExportedBy(this.importData.sourceUser); + // **Two independent questions, deliberately not one.** "Exported by", "Exported at" and + // "Imported at" all read from an `Import` row, so they are shown when there is one. The + // source row says the tag was read from the account, so it is shown when it was. Folding + // them into one branch reads fine and is wrong: a file-imported row whose import record + // has gone would then be labelled as coming from the Apple account, which is a claim + // about where somebody's data came from, made on the strength of a missing join. + if (this.importData == null) { + findViewById(R.id.device_settings_exported_by).setVisibility(View.GONE); + findViewById(R.id.device_settings_exported_at).setVisibility(View.GONE); + findViewById(R.id.device_settings_imported_at).setVisibility(View.GONE); + } else { + binding.setExportedAt(timestampFormat.format(new Date(this.importData.exportedAt))); + binding.setImportedAt(timestampFormat.format(new Date(this.importData.importedAt))); + binding.setExportedBy(this.importData.sourceUser); + } + + if (this.beaconInformation.isFromAccount()) { + binding.setSource(this.getString(R.string.source_your_apple_account)); + } else { + findViewById(R.id.device_settings_source).setVisibility(View.GONE); + } + + this.describeHowItIsBeingLookedFor(timestampFormat); + this.showTheOriginalNameOnlyWhereItMeansSomething(); // What is known without asking anything, drawn immediately. The shared heuristic can // improve on it, but it costs a Python interpreter, so this screen must be readable @@ -164,7 +217,22 @@ protected void onCreate(Bundle savedInstanceState) { Optional.ofNullable(this.beaconInformation.getNamingRecordModifiedByDevice()) .orElse("?")); - binding.setBatteryLevel(this.beaconInformation.getBatteryLevel() + ""); + // The number with its meaning beside it. The number stays first because that is what a + // bug report should quote and what every other source discusses - see + // BatteryLevelDescription for how much the labels are worth, and why nothing outside + // this debug panel reads any of it. + // **With the caveat attached, not left to the reader.** Apple's own devices are what + // update this field as they pass the accessory, so a tag imported from a zip carries + // whatever was true when the export was made and never changes it - possibly years ago. + // A number with no note beside it reads as current, and "Full" on a tag that has been + // flat since last spring is worse than showing nothing. + // + // Said for every tag rather than only for imported ones: somebody reading an account + // tag's row learns the rule at the moment it is relevant, which is what makes the + // imported case legible when they meet it. + binding.setBatteryLevel(BatteryLevelDescription.describe( + this, this.beaconInformation.getBatteryLevel()) + + "\n" + this.getString(R.string.battery_level_icloud_only)); binding.setDeviceModel(this.beaconInformation.getModel()); binding.setPairingDate(this.beaconInformation.getPairingDate()); binding.setProductId(this.beaconInformation.getProductId() + ""); @@ -257,8 +325,7 @@ private void visualiseDeviceEmoji() { ((MaterialButton)currentIconButton).setIcon(null); } else { currentIconButton.setText(null); - ((MaterialButton)currentIconButton).setIcon(AppCompatResources.getDrawable( - this, BeaconIcon.forBeacon(this.beaconInformation))); + BeaconIcon.applyTo((MaterialButton) currentIconButton, this.beaconInformation); } } @@ -287,11 +354,37 @@ private void handleEditDeviceName() { })); } + /** + * Whether renaming this tag changes the account or only this app. + * + *

An accessory read from iCloud keeps its name in one place - the naming record - + * so renaming it there is the whole rename, and it shows up in Find My on the owner's own + * devices. Everything else gets a nickname: one of the owner's own devices takes its name + * from several places and writing this record would leave Find My disagreeing with the + * device, and a tag imported from a file was never on this account to begin with. + * + *

Both halves have to be true, and neither is guessed. {@code isOwnDevice} is + * answered by the shared heuristic across the bridge, and is null until it does answer - + * which is treated as "not an accessory", because the cautious mistake is a local nickname + * and the other one writes to somebody's account. + */ + private boolean renamingWritesToTheAccount() { + return this.beaconInformation.isFromAccount() && Boolean.FALSE.equals(this.isOwnDevice); + } + private void saveUpdatedDeviceName(final String newDeviceName) { final String oldDeviceName = this.beaconInformation.getName(); if (oldDeviceName.equals(newDeviceName)) return; // nothing to do, no change + if (this.renamingWritesToTheAccount()) { + this.writeToTheAccount(newDeviceName, "", () -> { + this.binding.setDeviceName(this.beaconInformation.getName()); + this.binding.setPageTitle(this.getDeviceNameForTitle()); + }); + return; + } + this.beaconInformation.setUserOverrideName(newDeviceName); // save changes... var async = this.beaconRepo.storeUserBeaconOptions(new UserBeaconOptions( @@ -309,6 +402,69 @@ private void saveUpdatedDeviceName(final String newDeviceName) { error -> Log.e(TAG, "Error occurred while trying to update user-facing device name for beaconId=" + this.beaconId, error)); } + /** + * Write the change to iCloud, then to the stored record, then redraw. + * + *

In that order, and nothing local happens first. A rename that failed on the + * network and still changed the screen would be the app telling the user something about + * their account that is not true - so a failure says so and leaves everything exactly as it + * was, rather than quietly demoting itself to a nickname. + * + *

The stored naming record is edited rather than covered with a nickname, because a + * nickname wins at display time forever: the next rename made on the owner's iPhone would + * arrive and be hidden behind it. + * + * @param name the new name, or empty when only the emoji is changing. + * @param emoji the new emoji, or empty when only the name is changing. + * @param redraw what to run on the main thread once the change is real. + */ + private void writeToTheAccount(final String name, final String emoji, final Runnable redraw) { + this.showRenameInProgress(true); + + final AccessoryRenamer renamer = new AccessoryRenamer(new KeychainMembershipRepository( + UserAuthDataStore.getInstance(this.getApplicationContext()), + new AppCryptographyUtil())); + + this.accountRename = renamer + .rename(this.beaconId, this.beaconInformation.getOwnedBeaconPlistRaw(), + name, emoji) + .andThen(this.beaconRepo.renameStoredAccessory( + this.beaconId, + name.isEmpty() ? null : name, + emoji.isEmpty() ? null : emoji)) + .andThen(this.beaconRepo.getById(this.beaconId).firstOrError()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + reread -> { + this.beaconData = reread; + // Rebuilt from the record that was just written, so what is on screen + // is what the account holds - not a value this screen remembered + // sending. + this.beaconInformation = BeaconDataParser.parse(List.of(reread)).get(0); + this.hasNameChanges = true; + this.showRenameInProgress(false); + redraw.run(); + }, + error -> { + Log.e(TAG, "Could not rename " + this.beaconId + " in iCloud", error); + this.showRenameInProgress(false); + this.sayTheRenameDidNotHappen(); + }); + } + + private void showRenameInProgress(final boolean busy) { + this.findViewById(R.id.device_rename_progress).setVisibility(busy ? VISIBLE : GONE); + } + + private void sayTheRenameDidNotHappen() { + new MaterialAlertDialogBuilder(this, com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) + .setTitle(R.string.rename_failed_title) + .setIcon(R.drawable.warning_24px) + .setMessage(R.string.rename_failed_message) + .setPositiveButton(R.string.ok, null) + .show(); + } + private void handleEditDeviceEmoji() { ConstraintLayout emojiPicker = this.findViewById(R.id.emoji_picker_layout); emojiPicker.setVisibility(VISIBLE); @@ -339,6 +495,14 @@ private void handleEmojiIsPicked(EmojiViewItem emojiViewItem) { final String oldEmoji = this.beaconInformation.getEmoji(); if (oldEmoji != null && oldEmoji.equals(newEmoji)) return; // nothing to do, no change + if (this.renamingWritesToTheAccount()) { + this.writeToTheAccount("", newEmoji, () -> { + this.visualiseDeviceEmoji(); + this.binding.setPageTitle(this.getDeviceNameForTitle()); + }); + return; + } + this.beaconInformation.setUserOverrideEmoji(newEmoji); // save changes @@ -383,6 +547,12 @@ protected void onDestroy() { if (this.hardwareLookup != null && !this.hardwareLookup.isDisposed()) { this.hardwareLookup.dispose(); } + // **Disposed, but the write is not cancelled** - it is already on its way to Apple, and + // there is no undoing that from here. What this stops is the result landing on views + // that have gone. + if (this.accountRename != null && !this.accountRename.isDisposed()) { + this.accountRename.dispose(); + } super.onDestroy(); } @@ -432,16 +602,76 @@ private void describeHardwareInTheBackground() { final HardwareDescriber describer = AppDependencies.hardwareDescriber(); + // Both answers in one crossing. Each call starts a Python interpreter and parses the + // same plist, and the second question is only ever asked about the first one's failure. this.hardwareLookup = Observable - .fromCallable(() -> Optional.ofNullable(describer.describe(plist))) + .fromCallable(() -> Pair.create( + Optional.ofNullable(describer.describe(plist)), + Pair.create( + Optional.ofNullable(describer.whereToLookUp(plist)), + Optional.ofNullable(describer.isOwnDevice(plist))))) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( - described -> described.ifPresent(this.binding::setDeviceType), + answers -> { + answers.first.ifPresent(this.binding::setDeviceType); + if (answers.second.first.isPresent()) { + this.offerToLookTheVendorUp(); + } + // Left null when the heuristic could not say, which keeps renaming + // local. See the field. + this.isOwnDevice = answers.second.second.orElse(null); + + // Re-run now the answer is in: whether "original" means anything + // depends on it, and it arrives after the screen is drawn. + this.showTheOriginalNameOnlyWhereItMeansSomething(); + }, error -> Log.w(TAG, "Could not describe this accessory; " + "keeping the label already shown", error)); } + /** + * Say what the hex on the Type row is, and offer to settle it. + * + *

Shown only when nothing recognised the accessory, which is when Type reads something + * like {@code vendor 0x0ABC product 0x1234}. That is a real registry value rather than a + * failure, and somebody holding the thing can settle what it is in under a minute - but only + * if they are told the number means something. + * + *

Python decides whether there is anything to look up; this writes the sentence. + * {@code where_to_look_up} returns one already, and it is deliberately not used: it is + * English, composed in a module the desktop exporter shares, and this app ships in ten + * languages. Splitting it this way keeps the judgement in the one place that has the vendor + * table and the wording in the one place that gets translated. + */ + private void offerToLookTheVendorUp() { + final TextView hint = this.findViewById(R.id.device_type_lookup_hint); + + hint.setText(this.getString(R.string.vendor_lookup_hint, + String.format(Locale.ROOT, "0x%04X", this.beaconInformation.getVendorId()))); + hint.setVisibility(VISIBLE); + hint.setOnClickListener(view -> this.openBluetoothRegistry()); + } + + private void openBluetoothRegistry() { + final var properties = PropertiesUtil.getProperties(this.getAssets(), "app.properties"); + if (properties == null) { + Log.w(TAG, "Could not read app.properties; no registry link to open"); + return; + } + + final String url = properties.getProperty("bluetoothSigAssignedNumbers"); + if (url == null || url.isBlank()) { + Log.w(TAG, "No bluetoothSigAssignedNumbers configured in app.properties"); + return; + } + + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + if (intent.resolveActivity(this.getPackageManager()) != null) { + this.startActivity(intent); + } + } + private String getDeviceNameForTitle() { if (this.beaconInformation.isEmojiFilled()) { return String.format("%s %s", this.beaconInformation.getEmoji(), this.beaconInformation.getName()); @@ -495,7 +725,121 @@ private void redirectToDeviceHistory() { }); } + /** + * Say when this tag was last looked for, and how hard the app is still trying. + * + *

Otherwise "no last location known" is the whole story, and it covers three + * different situations that need different reactions: a tag nobody has walked past today, a + * tag being asked about less and less because it keeps answering nothing, and a tag the app + * has given up on. Only the last of those is worth a person's attention, and only it has + * anything they can do about it. + * + *

The notice is for everybody; the three rows below it are behind the debug switch, + * because "3 fruitless searches, next attempt in 4h" is a sentence for whoever is diagnosing + * a bug report rather than for the person who just wants their keys. + */ + private void describeHowItIsBeingLookedFor(final SimpleDateFormat timestamps) { + final boolean ignored = this.beaconInformation.isIgnored(); + + this.findViewById(R.id.device_ignored_notice).setVisibility(ignored ? VISIBLE : GONE); + if (ignored) { + this.findViewById(R.id.device_ignored_retry) + .setOnClickListener(view -> this.lookForItAgainNow()); + } + + final Long lastScan = this.beaconInformation.getLastScanAt(); + this.binding.setLastScanAttempt(lastScan == null + ? this.getString(R.string.debug_never) + : timestamps.format(new Date(lastScan))); + + final Long newest = this.beaconRepo.newestReportTimeFor(this.beaconId).blockingFirst() + .orElse(null); + this.binding.setLastResultAt(newest == null + ? this.getString(R.string.debug_never) + : timestamps.format(new Date(newest))); + + this.binding.setBackoffState(this.describeBackoff(timestamps)); + } + + private String describeBackoff(final SimpleDateFormat timestamps) { + final Long ignoredAt = this.beaconInformation.getIgnoredAt(); + if (ignoredAt != null) { + return this.getString( + R.string.debug_backoff_ignored, timestamps.format(new Date(ignoredAt))); + } + + final int scans = this.beaconInformation.getFruitlessScans(); + if (scans <= 0) { + return this.getString(R.string.debug_backoff_normal); + } + + final Long lastScan = this.beaconInformation.getLastScanAt(); + final long dueAt = (lastScan == null ? System.currentTimeMillis() : lastScan) + + WideScanBackoff.waitMillisAfter(scans); + + return this.getString(R.string.debug_backoff_waiting, scans, + DateUtils.getRelativeTimeSpanString( + dueAt, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS)); + } + + /** + * Hand the request back to the map, which owns fetching. + * + *

Deliberately the manual path. That one is not subject to the backoff at all, so a + * tag the app had stopped asking about is asked about immediately - which is the entire point + * of the button. Anything found clears the flag as an ordinary consequence of a successful + * search, rather than through a second code path that could disagree with the first. + */ + private void lookForItAgainNow() { + final Intent data = new Intent(); + data.putExtra(RETRY_IGNORED_BEACON, this.beaconId); + this.setResult(RESULT_OK, data); + this.finish(); + } + + /** Asks whoever launched this screen to search for the named tag right now. */ + public static final String RETRY_IGNORED_BEACON = "retryIgnoredBeacon"; + + /** + * Hide "original name" and "original emoji" for a tag whose name is not a nickname. + * + *

"Original" is only a coherent idea where something is layered over it. A tag + * imported from a file, or one of the owner's own devices, keeps the name Apple gave it and + * shows a local nickname on top - so the two are different things and both are worth seeing. + * An accessory read from iCloud has no such split: renaming it writes to the account, so the + * name on screen is the name, and a row labelled "original" showing the same string + * invites the reader to hunt for a difference that cannot exist. + * + *

Deliberately the same predicate that decides where a rename goes. The two questions are + * one question - "is this a name we can actually change" - and answering it twice is how they + * end up disagreeing. + * + *

Called again once the heuristic answers, because it decides this and arrives after the + * screen is drawn. + */ + private void showTheOriginalNameOnlyWhereItMeansSomething() { + final int visibility = this.renamingWritesToTheAccount() ? GONE : VISIBLE; + + this.findViewById(R.id.settings_debug_device_name_original).setVisibility(visibility); + this.findViewById(R.id.settings_debug_device_emoji_original).setVisibility(visibility); + } + private void onClickDeviceDelete() { + // A tag read from the Apple account is a cache of what Apple holds, so marking it + // removed here would undo itself at the next refresh - the row is written back with + // `is_removed = 0` and the tag reappears with no explanation. Removing it for real is + // done in Find My. See MyDevicesListActivity#confirmRemoveSelection, which says the + // same thing for a selection. + if (this.beaconInformation.isFromAccount()) { + new MaterialAlertDialogBuilder(this, com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) + .setTitle(R.string.cannot_remove_account_tag_title) + .setIcon(R.drawable.help_center_24px) + .setMessage(R.string.cannot_remove_account_tag_message) + .setPositiveButton(R.string.ok, null) + .show(); + return; + } + var dialog = new MaterialAlertDialogBuilder(this, com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) .setTitle(R.string.remove_device) .setIcon(R.drawable.delete_24px) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java new file mode 100644 index 00000000..18338a9f --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java @@ -0,0 +1,652 @@ +package dev.wander.android.opentagviewer; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.text.format.DateFormat; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.activity.OnBackPressedCallback; +import androidx.appcompat.app.AppCompatActivity; + +import com.google.android.material.textfield.TextInputEditText; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Optional; + +import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; +import dev.wander.android.opentagviewer.db.repo.BeaconRepository; +import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.icloud.EscrowPasscode; +import dev.wander.android.opentagviewer.python.icloud.ICloudAccessory; +import dev.wander.android.opentagviewer.python.icloud.ICloudException; +import dev.wander.android.opentagviewer.python.icloud.ICloudFailure; +import dev.wander.android.opentagviewer.python.icloud.ICloudFetch; +import dev.wander.android.opentagviewer.python.icloud.ICloudService; +import dev.wander.android.opentagviewer.python.icloud.KeychainMembership; +import dev.wander.android.opentagviewer.python.icloud.RecoverableDevice; +import dev.wander.android.opentagviewer.ui.RecoverableDeviceIcon; +import dev.wander.android.opentagviewer.ui.login.StepTransition; +import dev.wander.android.opentagviewer.ui.login.StepTransition.Direction; +import dev.wander.android.opentagviewer.util.android.PropertiesUtil; +import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; +import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; + +/** + * Reading the tags on the signed-in Apple account, instead of importing a zip. + * + *

The flow is four calls with a person answering something between them - open, list what the + * keychain can be recovered from, unlock with a device passcode, fetch - and this is the screen + * around them. It talks only to {@link ICloudService}, so every state it has to handle can be + * produced in a test; the real implementation needs an Apple account in conditions nobody can + * arrange on demand, and the states worth getting right are precisely the ones a working account + * will never be in. + * + *

The session is closed in {@code onDestroy}, in a finally-shaped way: two of those + * calls hold sockets, and an abandoned session leaks them for the life of the process. + */ +public class FetchFromICloudActivity extends AppCompatActivity { + private static final String TAG = FetchFromICloudActivity.class.getSimpleName(); + + /** + * The mutually exclusive steps, listed once. + * + *

So showing one is "show this" rather than every caller remembering to hide the other + * five - which is the kind of thing that leaves two stacked on top of each other. + */ + private static final int[] STEPS = { + R.id.icloud_loading_container, + R.id.icloud_device_container, + R.id.icloud_passcode_container, + R.id.icloud_no_tags_container, + R.id.icloud_retry_container, + R.id.icloud_results_container, + }; + + /** Set when the screen should leave and let the caller open the file picker instead. */ + public static final String RESULT_WANTS_FILE_IMPORT = "wantsFileImport"; + + private ICloudService icloud; + + private List devices = List.of(); + + private RecoverableDevice chosenDevice; + + /** + * Which attempt the next press will be, starting at 1. + * + *

Capped at {@link ICloudService#MAX_UNLOCK_ATTEMPTS}, and the cap is respected here + * rather than in Python because this is where the button is. Attempts are probably a limited + * resource on Apple's end and what this service allows is not established, so spending them + * is deliberate. + */ + private int attempt = 1; + + /** Whether anything was written, so the caller knows to redraw its list. */ + private boolean importedSomething = false; + + private BeaconRepository beaconRepo; + + private KeychainMembershipRepository membershipRepo; + + /** + * The one button, at the bottom, relabelled per step. + * + *

It was four - one at the bottom of whatever content each step happened to have - so it + * moved up and down the screen as the flow went on. One that never moves is a great deal + * calmer, and it cannot end up repeating the heading the way the passcode step's did. + */ + private Button primaryButton; + + /** + * Back, in the footer, shown only where there is a step to go back to. + * + *

The system back already did this, but system back is not an affordance: nothing on the + * passcode step said the device list was still there, so picking the wrong device out of two + * looked like a decision you could not take back. + */ + private Button backButton; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + this.setContentView(R.layout.activity_fetch_from_icloud); + this.beaconRepo = new BeaconRepository( + OpenTagViewerDatabase.getInstance(this.getApplicationContext())); + this.membershipRepo = new KeychainMembershipRepository( + UserAuthDataStore.getInstance(this.getApplicationContext()), + new AppCryptographyUtil()); + WindowPaddingUtil.insertUITopPadding(this.findViewById(R.id.icloud_scroll)); + + if (this.getSupportActionBar() != null) { + this.getSupportActionBar().hide(); + } + + this.primaryButton = this.findViewById(R.id.icloud_primary_button); + this.backButton = this.findViewById(R.id.icloud_back_button); + // The same route the system back takes, so the two cannot disagree. + this.backButton.setOnClickListener(v -> this.onBackWithin()); + this.findViewById(R.id.icloud_no_tags_wiki_link) + .setOnClickListener(v -> this.openExportGuide()); + + this.getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + onBackWithin(); + } + }); + + this.start(); + } + + /** + * Back, meaning "the step before this one" where there is one. + * + *

Not simply closing the screen. From the passcode step the step before it is the + * device list, and a back press that abandoned the whole errand instead would make choosing + * the wrong device out of two an expensive mistake - it costs the sign-in, the wait, and + * finding the button again. + * + *

Swallowed entirely while a call is in flight. There is nothing to go back to mid-call, + * and leaving then would strand a keychain unlock that is already talking to Apple. + */ + private void onBackWithin() { + if (this.isShowing(R.id.icloud_loading_container)) { + Log.d(TAG, "Back pressed while a call was in flight; ignoring"); + return; + } + + if (this.isShowing(R.id.icloud_passcode_container) && this.devices.size() > 1) { + // Only worth going back to when there was a choice. With one device the list is a + // single button and returning to it is a dead end that looks like a bug. + this.showDevices(this.devices, Direction.BACK); + return; + } + + this.finish(); + } + + private boolean isShowing(final int stepId) { + return this.findViewById(stepId).getVisibility() == VISIBLE; + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (this.icloud != null) { + this.icloud.close(); + this.icloud = null; + } + } + + /** Open a session and ask what the keychain can be recovered from. */ + private void start() { + this.showWaiting(R.string.icloud_loading_looking_for_devices); + + if (this.icloud != null) { + this.icloud.close(); + } + + this.icloud = AppDependencies.icloud(); + + if (this.icloud == null) { + // Same recovery as a session that has expired, and handled by whoever launched this + // rather than by showing a dead screen. + // + // **Deliberately does not name a cause.** It used to say "no signed-in account", + // which is only one of the two ways this is null - the other is a session that could + // not be opened - and when that happened the log confidently blamed the sign-in while + // the real error sat two lines above it. Whatever failed has already logged why. + Log.e(TAG, "No usable iCloud session, so the flow cannot start." + + " Either nobody is signed in, or opening the session failed - see above."); + this.finish(); + return; + } + + // **The passcode is asked for once, ever.** If this app already joined, it reads as the + // member it is; only a first run, or a membership the account no longer honours, reaches + // the device list at all. + var async = this.icloud.open() + .andThen(this.membershipRepo.get().firstOrError()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::continueWith, this::showFailure); + } + + private void continueWith(final Optional membership) { + if (membership.isEmpty()) { + this.askForADevice(); + return; + } + + this.showWaiting(R.string.icloud_loading_importing); + + var async = this.icloud.resume(membership.get().getPeerJson()) + .andThen(this.icloud.fetch()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::importEverything, this::onStoredMembershipFailed); + } + + /** + * The stored membership stopped working, so fall back to asking. + * + *

Not an error to show. The peer may simply have been removed from the account - + * which is how somebody revokes this app - and the way forward is a passcode and a fresh + * join, which is precisely the flow a first run takes. Anything else is a real failure. + */ + private void onStoredMembershipFailed(final Throwable error) { + final ICloudFailure failure = error instanceof ICloudException + ? ((ICloudException) error).getFailure() : ICloudFailure.UNKNOWN; + + if (failure != ICloudFailure.MEMBERSHIP_UNUSABLE) { + this.showFailure(error); + return; + } + + Log.w(TAG, "The stored membership no longer reads the account; asking again"); + // Forgotten, or every later run retries keys the account has stopped honouring. + var async = this.membershipRepo.forget() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::askForADevice, + forgetFailed -> { + Log.e(TAG, "Could not forget the membership", forgetFailed); + this.askForADevice(); + }); + } + + private void askForADevice() { + this.showWaiting(R.string.icloud_loading_looking_for_devices); + + var async = this.icloud.recoveryOptions() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(devices -> this.showDevices(devices, Direction.FORWARD), + this::showFailure); + } + + private void showDevices( + final List recoverable, final Direction direction) { + this.devices = recoverable; + + final LinearLayout list = this.findViewById(R.id.icloud_device_list); + list.removeAllViews(); + + for (final RecoverableDevice device : recoverable) { + final View tile = this.getLayoutInflater() + .inflate(R.layout.icloud_device_tile, list, false); + + this.fillTile(tile, device); + tile.setOnClickListener(v -> this.chooseDevice(device)); + list.addView(tile); + } + + this.showOnly(R.id.icloud_device_container, R.string.icloud_unlock_title, + direction); + } + + private void chooseDevice(final RecoverableDevice device) { + this.chosenDevice = device; + this.attempt = 1; + + this.fillTile(this.findViewById(R.id.icloud_passcode_device), device); + ((TextInputEditText) this.findViewById(R.id.icloud_passcode_input)).setText(""); + this.findViewById(R.id.icloud_passcode_error_container).setVisibility(GONE); + + this.updateAttemptCounter(); + this.showOnly(R.id.icloud_passcode_container, R.string.icloud_unlock_title, Direction.FORWARD); + } + + private void updateAttemptCounter() { + final TextView counter = this.findViewById(R.id.icloud_attempts_text); + counter.setText(this.getString( + R.string.icloud_attempt_x_of_y, this.attempt, ICloudService.MAX_UNLOCK_ATTEMPTS)); + // Hidden on the first go: "Attempt 1 of 3" before anything has been tried reads as a + // warning, and there is nothing to warn about yet. + counter.setVisibility(this.attempt > 1 ? VISIBLE : GONE); + } + + private void submitPasscode() { + final TextInputEditText input = this.findViewById(R.id.icloud_passcode_input); + final String passcode = input.getText() == null ? "" : input.getText().toString(); + + if (passcode.isEmpty()) { + return; + } + + this.showWaiting(R.string.icloud_loading_unlocking); + + // Unlock, join, store, then read - in that order, and the store is not optional. + // + // **By the time the join returns, a peer exists on the user's account** whether or not + // anything after it succeeds, and the keys that came back are the only copy of the means + // to use it. So the membership is written before the fetch, and a failure to write stops + // the flow rather than being logged past: carrying on would leave them with a peer this + // app can neither use nor clean up, and nothing on screen to say so. + var async = this.icloud.unlock(this.chosenDevice.getSerial(), passcode) + .andThen(this.icloud.join(EscrowPasscode.generate())) + .flatMapCompletable(this.membershipRepo::store) + .andThen(this.icloud.fetch()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::importEverything, this::onUnlockFailed); + } + + /** + * A rejected passcode, which is not proof it was wrong. + * + *

FindMy.py's first advice is to try the same one again, because the exchange has been + * seen to fail intermittently and then succeed. The copy says that; do not reword it into + * "incorrect passcode". + */ + private void onUnlockFailed(final Throwable error) { + final ICloudFailure failure = error instanceof ICloudException + ? ((ICloudException) error).getFailure() : ICloudFailure.UNKNOWN; + + if (failure != ICloudFailure.PASSCODE_REJECTED) { + this.showFailure(error); + return; + } + + this.attempt++; + + if (this.attempt > ICloudService.MAX_UNLOCK_ATTEMPTS) { + Log.w(TAG, "Out of unlock attempts for " + this.chosenDevice.getSerial()); + this.showOnly(R.id.icloud_retry_container, R.string.icloud_service_unsure_title, Direction.FORWARD); + ((TextView) this.findViewById(R.id.icloud_retry_body)) + .setText(R.string.icloud_passcode_rejected); + return; + } + + this.findViewById(R.id.icloud_passcode_error_container).setVisibility(VISIBLE); + this.updateAttemptCounter(); + this.showOnly(R.id.icloud_passcode_container, R.string.icloud_unlock_title, Direction.NONE); + } + + /** + * Take everything the account holds, write it, and then show what was taken. + * + *

Everything, with nothing to choose. Importing from the account is all of it; the + * screen that follows is an overview of what arrived, not a picker. Choosing a subset is what + * exporting is for, and that lives on the device list behind a long press. + */ + private void importEverything(final ICloudFetch fetched) { + if (fetched.isEmpty()) { + this.showResults(fetched); + return; + } + + this.setLoadingText(R.string.icloud_loading_importing); + + final List wanted = new ArrayList<>(); + for (final ICloudAccessory accessory : fetched.getAccessories()) { + wanted.add(accessory.getBeaconId()); + } + + var async = this.icloud.records(wanted) + .flatMap(this.beaconRepo::refreshAccountBeacons) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(held -> { + Log.i(TAG, "Holding " + held.size() + " beacons for the account"); + this.importedSomething = true; + this.showResults(fetched); + }, this::showFailure); + } + + private void showResults(final ICloudFetch fetched) { + if (fetched.isEmpty()) { + // An account with a Mac on it but no tags. Same advice as having nothing to recover + // from, reached a step later. + this.showOnly(R.id.icloud_no_tags_container, R.string.icloud_no_tags_title, Direction.FORWARD); + return; + } + + ((TextView) this.findViewById(R.id.icloud_results_found)).setText( + this.getString(R.string.icloud_found_x_tags, fetched.getAccessories().size())); + + final TextView skipped = this.findViewById(R.id.icloud_results_skipped); + // Named rather than dropped quietly: "fewer tags than expected" and "some of those were + // never tags" look identical from outside, and the second is the common one. + skipped.setVisibility(fetched.getSkipped().isEmpty() ? GONE : VISIBLE); + skipped.setText(this.getString( + R.string.icloud_x_were_not_tags, fetched.getSkipped().size())); + + final LinearLayout list = this.findViewById(R.id.icloud_results_list); + list.removeAllViews(); + + for (final ICloudAccessory accessory : fetched.getAccessories()) { + final View row = this.getLayoutInflater() + .inflate(R.layout.icloud_found_accessory, list, false); + + ((TextView) row.findViewById(R.id.icloud_found_label)).setText(accessory.getLabel()); + + final TextView details = row.findViewById(R.id.icloud_found_details); + // What an accessory with no name has instead of one: what kind of thing it is, its + // serial, when it was paired. "unnamed" three times over is not a list to choose + // from. + details.setText(accessory.getDetails()); + details.setVisibility(accessory.getDetails().isEmpty() ? GONE : VISIBLE); + + list.addView(row); + } + + // **Not `icloud_found_x_tags`**, which is a format string with a `%1$d` in it - used as + // a heading it renders the placeholder literally, which is what shipped in the first + // screenshot of this screen. + this.showOnly(R.id.icloud_results_container, R.string.icloud_results_title, Direction.FORWARD); + } + + /** Whatever went wrong, on the screen written for it. */ + private void showFailure(final Throwable error) { + final ICloudFailure failure = error instanceof ICloudException + ? ((ICloudException) error).getFailure() : ICloudFailure.UNKNOWN; + final String detail = error instanceof ICloudException + ? ((ICloudException) error).getDetail() : String.valueOf(error.getMessage()); + + Log.w(TAG, "iCloud flow stopped: " + failure + " - " + detail); + + switch (failure) { + case NOTHING_TO_RECOVER_FROM: + // Final. This account has nothing that can ever unlock its keychain, so the + // import path is the answer rather than a retry. + this.showOnly(R.id.icloud_no_tags_container, R.string.icloud_no_tags_title, Direction.FORWARD); + break; + + case NOT_SIGNED_IN: + Log.e(TAG, "The account is not usable, so this screen has nothing to do"); + this.finish(); + break; + + case SERVICE_UNSURE: + default: + // Everything unrecognised lands here on purpose: "try again later" is the safe + // thing to say about a failure whose cause is not established, and it is a long + // way better than telling somebody they own no tags. + this.showOnly(R.id.icloud_retry_container, R.string.icloud_service_unsure_title, Direction.FORWARD); + ((TextView) this.findViewById(R.id.icloud_retry_body)).setText( + failure == ICloudFailure.SERVICE_UNSURE + ? this.getString(R.string.icloud_service_unsure_body) + : detail); + break; + } + } + + /** Whether this screen brought anything in, so the device list knows to redraw. */ + public static final String RESULT_IMPORTED = "importedFromAccount"; + + @Override + public void finish() { + if (this.importedSomething) { + final android.content.Intent data = new android.content.Intent(); + data.putExtra(RESULT_IMPORTED, true); + this.setResult(RESULT_OK, data); + } + super.finish(); + } + + /** + * Open the wiki page describing how to make an export. + * + *

The same page the device list links to, opened the same way: read from + * {@code app.properties} rather than written here, so there is one URL to change. + * + *

Worth having on this screen in particular. The person reading it cannot produce a + * bundle themselves - somebody else has to - so a link they can forward is the difference + * between being told what they need and being able to ask for it. + */ + private void openExportGuide() { + final var properties = PropertiesUtil.getProperties(this.getAssets(), "app.properties"); + if (properties == null) { + Log.w(TAG, "Could not read app.properties; no export guide link to open"); + return; + } + + final String url = properties.getProperty("exportWikiPage"); + if (url == null || url.isBlank()) { + Log.w(TAG, "No exportWikiPage configured in app.properties"); + return; + } + + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + if (intent.resolveActivity(this.getPackageManager()) != null) { + this.startActivity(intent); + } + } + + private void leaveForFileImport() { + final android.content.Intent data = new android.content.Intent(); + data.putExtra(RESULT_WANTS_FILE_IMPORT, true); + this.setResult(RESULT_OK, data); + this.finish(); + } + + /** + * Show the spinner, saying what is being waited for. + * + *

Its own heading, not the next step's. Every wait used to borrow "Unlock your + * Apple keychain", so the screen looked like the device list failing to populate rather than + * like work happening - which is exactly how it read to somebody watching. + */ + private void showWaiting(final int captionResId) { + this.showOnly(R.id.icloud_loading_container, R.string.icloud_loading_title, + Direction.NONE); + this.setLoadingText(captionResId); + } + + private void setLoadingText(final int stringResId) { + ((TextView) this.findViewById(R.id.icloud_loading_text)).setText(stringResId); + } + + /** + * Show one step and hide the rest. + * + *

Listed once so showing a step is "show this one" rather than every caller remembering + * to hide each of the others - the mistake that leaves two steps stacked on each other. + */ + /** + * The quiet second line of a device tile: what it is, its serial, when the record was made. + * + *

The model is only worth showing when the user named the device - otherwise the name + * already is the model class and repeating it says nothing. + * + *

"added", never "last used". This is when the escrow record was created. A record + * made three months ago says nothing about whether that phone was used this morning, and + * telling somebody otherwise sends them looking for the wrong device. + */ + /** Put a device into a tile - the same one whether it is being chosen or already was. */ + private void fillTile(final View tile, final RecoverableDevice device) { + ((ImageView) tile.findViewById(R.id.icloud_device_icon)) + .setImageResource(RecoverableDeviceIcon.forDevice(device)); + ((TextView) tile.findViewById(R.id.icloud_device_name)).setText(device.displayName()); + ((TextView) tile.findViewById(R.id.icloud_device_badges)).setText(this.badgesFor(device)); + } + + private String badgesFor(final RecoverableDevice device) { + final List parts = new ArrayList<>(); + + if (device.hasUserGivenName() && !device.getModelClass().isBlank()) { + parts.add(device.getModelClass()); + } + if (device.getSerial() != null && !device.getSerial().isBlank()) { + parts.add(device.getSerial()); + } + if (device.getEscrowedAtMs() > 0) { + // Medium, so the month is a word. The short format is numeric and ambiguous - + // "3/12/24" is March in one country and December in another, on a screen whose whole + // job is helping somebody recognise which of their devices this is. + parts.add(this.getString(R.string.icloud_device_added_on, + DateFormat.getMediumDateFormat(this) + .format(new Date(device.getEscrowedAtMs())))); + } + + return String.join(" · ", parts); + } + + /** Put the one button at the bottom to work for whichever step is showing. */ + private void setPrimaryButton(final int textResId, final Runnable action) { + this.primaryButton.setVisibility(VISIBLE); + this.primaryButton.setText(textResId); + this.primaryButton.setOnClickListener(v -> action.run()); + } + + private void hidePrimaryButton() { + this.primaryButton.setVisibility(GONE); + this.primaryButton.setOnClickListener(null); + } + + private void showOnly(final int stepId, final int titleResId, final Direction direction) { + View outgoing = null; + for (final int candidate : STEPS) { + if (candidate == stepId) { + continue; + } + final View step = this.findViewById(candidate); + if (outgoing == null && step.getVisibility() == VISIBLE) { + outgoing = step; + continue; + } + // Already off screen, or a second visible step, which should not happen. Hidden + // without ceremony either way: only one thing can animate out, and a step left + // half-faded by an earlier swap would come back that way. + StepTransition.swap(step, null, Direction.NONE); + } + + StepTransition.swap(outgoing, this.findViewById(stepId), direction); + + // The heading names the step, so it travels with it. Set first, so nothing has to wait + // for the animation to read what it says. + final TextView title = this.findViewById(R.id.icloud_step_title); + title.setText(titleResId); + StepTransition.enter(title, direction); + + // Only where there is an earlier step: on the passcode step with a choice of devices. + // Anywhere else back leaves the screen, and a button that closes the screen is not what + // somebody expects from an arrow pointing left. + this.backButton.setVisibility( + stepId == R.id.icloud_passcode_container && this.devices.size() > 1 + ? VISIBLE : GONE); + + if (stepId == R.id.icloud_passcode_container) { + this.setPrimaryButton(R.string.icloud_unlock_action, this::submitPasscode); + } else if (stepId == R.id.icloud_results_container) { + this.setPrimaryButton(R.string.icloud_results_done, this::finish); + } else if (stepId == R.id.icloud_no_tags_container) { + this.setPrimaryButton(R.string.icloud_import_from_file, this::leaveForFileImport); + } else if (stepId == R.id.icloud_retry_container) { + this.setPrimaryButton(R.string.icloud_try_again, this::start); + } else { + // The device list and the spinner have nothing to press: on the list you choose a + // tile, and during a call there is nothing to do but wait. + this.hidePrimaryButton(); + } + } +} 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 9f11dc73..7cb5e0b4 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -56,6 +56,7 @@ import dev.wander.android.opentagviewer.ui.maps.AMapProvider; import dev.wander.android.opentagviewer.ui.maps.MapMarker; 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.dialog.MaterialAlertDialogBuilder; @@ -81,6 +82,7 @@ import dev.wander.android.opentagviewer.databinding.ActivityMapsBinding; import dev.wander.android.opentagviewer.anisette.LocalAnisette; import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; +import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; import dev.wander.android.opentagviewer.db.datastore.UserCacheDataStore; import dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore; import dev.wander.android.opentagviewer.db.repo.UserAuthRepository; @@ -102,6 +104,7 @@ import dev.wander.android.opentagviewer.ui.maps.TagListSwiperHelper; import dev.wander.android.opentagviewer.util.LogCollectorUtil; import dev.wander.android.opentagviewer.util.MapUtils; +import dev.wander.android.opentagviewer.python.icloud.AccountRefresher; import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; import dev.wander.android.opentagviewer.util.android.PermissionUtil; import dev.wander.android.opentagviewer.ui.maps.VectorImageGeneratorUtil; @@ -111,6 +114,7 @@ import dev.wander.android.opentagviewer.util.rx.BeaconLocationHistory; import dev.wander.android.opentagviewer.util.rx.LongFetchBannerState; import dev.wander.android.opentagviewer.util.rx.MarkerFocus; +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 io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; @@ -131,6 +135,28 @@ public class MapsActivity extends AppCompatActivity implements IMapProvider.OnMa private static final int HOURS_TO_GO_BACK_24H = 24; + /** + * How far back to look for a tag nothing has ever searched for. + * + *

A day is the wrong window for a tag's first fetch. Everything else here asks + * about the time since the last fetch, which is right for a tag the app has been watching - + * but a tag that has just arrived from a zip or from the account has no history at all, and + * one last seen on Tuesday comes back empty from a window that starts this morning. It then + * sits in My Devices saying "No last location known" while its locations are sitting on + * Apple's servers, findable by opening the history screen and paging back - which is exactly + * how @parawanderer found this. + * + *

Seven days because that is all Apple keeps, so it is the whole of what can be had, and + * one request rather than a widening loop. It costs little: ~672 key indices for an aligned + * tag against the 2000 that makes a search count as expensive, so it does not look like a + * wide search and cannot push a healthy tag towards the backoff. + * + *

Applied per beacon and only while {@code last_scan_at} is null, so it happens once and + * then stops - a tag that has been searched drops back to the ordinary window whether or not + * that first search found anything. + */ + private static final int HOURS_TO_GO_BACK_FIRST_TIME = 24 * 7; + private static final long WAIT_BEFORE_REFETCH = 1000 * 60; // 1 MINUTE private static final float CAMERA_ON_MAP_INITIAL_ZOOM = 16.0f; // see: https://developers.google.com/maps/documentation/android-sdk/views#zoom @@ -185,6 +211,29 @@ public void setZIndex(String markerId, float zIndex) { /** When a refresh is allowed, and how much history it should ask for. See RefreshPolicyTest. */ // Shared across activity instances on purpose - see RefreshPolicy.shared. Rebuilding this // screen must not look like an app that has never spoken to Apple. + /** + * How often the app re-reads the Apple account on its own. + * + *

Six hours, against the location refresh's one minute. These answer different + * questions: locations change constantly and are the point of the map, while what tags exist + * changes when somebody adds one in Find My or renames it - rare, and never urgent. A read + * also decrypts every record on the account and queues behind the location fetches, so doing + * it eagerly costs the user's own work rather than just bandwidth. + */ + private static final long WAIT_BEFORE_REREADING_ACCOUNT = 6L * 60 * 60 * 1000; + + private final AccountReadPolicy accountReadPolicy = + new AccountReadPolicy(WAIT_BEFORE_REREADING_ACCOUNT); + + /** + * Whether an Apple account is linked, as of the last time this screen resumed. + * + *

Cached rather than asked on every tick: the answer lives in the encrypted datastore, and + * the tick runs every minute. It can only become true while this screen is away - linking + * happens on another one - so resuming is exactly when it is worth asking again. + */ + private volatile boolean accountIsLinked = false; + private final RefreshPolicy refreshPolicy = RefreshPolicy.shared(WAIT_BEFORE_REFETCH, HOURS_TO_GO_BACK_24H); @@ -265,6 +314,15 @@ public void setZIndex(String markerId, float zIndex) { || data.getStringExtra("deviceWasChanged") != null)) { this.handleDeviceListChanged(); } + + // The tag page has no way to fetch - the picker, the Python service and the + // card that shows the answer all live here - so it asks. See + // DeviceInfoActivity#lookForItAgainNow. + final String retry = data == null + ? null : data.getStringExtra(DeviceInfoActivity.RETRY_IGNORED_BEACON); + if (retry != null) { + this.lookForAnIgnoredTagAgain(retry); + } } } ); @@ -427,6 +485,7 @@ protected void onResume() { // TODO: when a user changes their anisette URL in settings and returns here, this should be able to deal with querying the new URL + this.rememberWhetherAnAccountIsLinked(); this.refreshIfAllowed(); this.reSchedulePeriodicTagLocationRefresher(); @@ -454,6 +513,7 @@ private void reSchedulePeriodicTagLocationRefresher() { this.nextLocationRefreshTask = () -> { refreshSchedulerHandler.postDelayed(this.nextLocationRefreshTask, WAIT_BEFORE_REFETCH); this.refreshIfAllowed(); + this.rereadTheAccountIfAllowed(); }; refreshSchedulerHandler.postDelayed(this.nextLocationRefreshTask, WAIT_BEFORE_REFETCH); } @@ -478,6 +538,93 @@ private void refreshIfAllowed() { Log.d(TAG, "Automatic scheduled refresh complete! Next automatic refresh will be in " + WAIT_BEFORE_REFETCH + " ms"); } + /** + * Ask the account what it holds now, if it is time and nothing else is running. + * + *

Silent by design. Nobody asked for this, so there is nothing to show and nothing + * to report: it succeeds by the device list quietly being right, and by the map picking up a + * tag that was added in Find My without anybody going to look for a button. + * + *

The device list is only rebuilt when something actually changed, because rebuilding it + * moves rows under whoever is reading them. + */ + private void rereadTheAccountIfAllowed() { + final long now = System.currentTimeMillis(); + + final AccountReadPolicy.Decision decision = this.accountReadPolicy.decide( + now, this.accountIsLinked, PythonAppleService.isBusy()); + + if (!decision.shouldRead()) { + return; + } + + // Marked before it runs, not after. A read that takes twenty minutes queued behind a + // location fetch must not earn another one the moment it finishes. + this.accountReadPolicy.markRead(now); + Log.d(TAG, "Re-reading the Apple account in the background"); + + var async = new AccountRefresher( + new KeychainMembershipRepository( + UserAuthDataStore.getInstance(this.getApplicationContext()), + new AppCryptographyUtil()), + this.beaconRepo) + .refresh() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + held -> Log.i(TAG, "Background account read holds " + held.size() + " tags"), + error -> Log.w(TAG, "Background account read failed", error)); + } + + private void rememberWhetherAnAccountIsLinked() { + var async = new KeychainMembershipRepository( + UserAuthDataStore.getInstance(this.getApplicationContext()), + new AppCryptographyUtil()) + .get() + .firstOrError() + .subscribeOn(Schedulers.io()) + .subscribe( + held -> this.accountIsLinked = held.isPresent(), + error -> Log.w(TAG, "Could not tell whether an account is linked", error)); + } + + /** + * Search for one tag the app had given up on, because somebody asked. + * + *

Through the manual fetch path, which the backoff does not touch. That is the + * whole point of the button: the tag is skipped by the scheduled fetches precisely because + * it has answered nothing for months, and a person pressing "check now" is overriding that + * judgement, which they are entitled to do. + * + *

Nothing here clears the ignored flag. A successful search does that on its own, in the + * same place every other successful search does - see + * {@code OwnedBeaconDao#recordSuccessfulScan}. A second path that cleared it separately + * could disagree with the first, and would be the version that quietly un-ignores a tag that + * still found nothing. + */ + private void lookForAnIgnoredTagAgain(final String beaconId) { + final BeaconData beacon = this.beacons.get(beaconId); + if (beacon == null) { + Log.w(TAG, "Asked to look for " + beaconId + " again, but it is not on this screen"); + return; + } + + Log.i(TAG, "Looking again for " + beaconId + ", which had been set aside"); + Toast.makeText(this.getApplicationContext(), R.string.tag_ignored_retrying, LENGTH_LONG) + .show(); + + // **The whole week, not a day.** This tag was set aside precisely because it has not + // reported in a very long time, so asking about the last twenty-four hours is close to + // guaranteed to find nothing - and the person tapping "look again" would be told the + // same thing they were told before, having done the one thing the screen offered them. + var async = this.fetchLastReportsFor( + beaconId, beacon.getInfo().getOwnedBeaconPlistRaw(), + HOURS_TO_GO_BACK_FIRST_TIME) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + reports -> this.handleDeviceListChanged(), + error -> Log.e(TAG, "Looking again for " + beaconId + " failed", error)); + } + public void onClickMoreSettings(View view) { Log.d(TAG, "Global more button was clicked"); @@ -645,11 +792,28 @@ private void onImportFilePicked(Intent data, final String passcode) { * relative to each other does not matter. */ .flatMapCompletable(storedBeacons -> RxFlows.allThen( - // Once, after every accessory has landed, rather than per accessory. + // A last pass once everything has landed, for anything the per-accessory + // passes below could not resolve. this.updateBeaconGeocodings(), this.fetchLastReports( BeaconRepository.plistFallbacks(storedBeacons.getOwnedBeacons()), HOURS_TO_GO_BACK_24H) - .doOnNext(this::addBeaconLocationsToCurrent), + // **Geocoded as each accessory lands, not only at the end.** This + // used to be a bare doOnNext with the geocoding left to the `then` + // above, which runs after the whole batch - and a batch is one + // sequential fetch per tag, where a tag with no key alignment record + // takes minutes. So every card sat showing raw coordinates for the + // length of the run, despite geocoding being a Google call that had + // nothing to wait for. Cheap to repeat: a beacon whose location has + // not moved since its last geocoding is skipped. + .concatMap(reports -> { + this.addBeaconLocationsToCurrent(reports); + // Same reason as the periodic refresh: the model is not the + // screen, and waiting for the slowest tag to redraw the fast + // ones is the whole complaint. + this.runOnUiThread(this::showLastDeviceLocations); + return this.updateBeaconGeocodings() + .andThen(Observable.just(reports)); + }), BeaconDataParser.parseAsync(BeaconCombinerUtil.combine(storedBeacons)) .doOnNext(this::addBeaconToCurrent) )) @@ -1180,17 +1344,22 @@ private synchronized void showBeaconOnMap(final BeaconInformation beacon, final // 创建自定义标记图标 android.graphics.Bitmap iconBitmap; + // **Resolved from the theme, not read from the colour resource.** The two agree in every + // built-in theme, including at night - and stop agreeing the moment system colours are + // on, because DynamicColors rewrites the theme attribute and cannot rewrite a fixed + // value in colors.xml. The cards took the wallpaper's tint and the pins did not. See + // MarkerPalette. if (beacon.isEmojiFilled()) { iconBitmap = VectorImageGeneratorUtil.makeMarker( getResources(), beacon.getEmoji(), - getColor(R.color.md_theme_background)); + MarkerPalette.fill(this)); } else { iconBitmap = VectorImageGeneratorUtil.makeMarker( getResources(), R.drawable.apple, - getColor(R.color.md_theme_background), - getColor(R.color.greyish) + MarkerPalette.fill(this), + MarkerPalette.icon(this) ); } @@ -1336,7 +1505,7 @@ private synchronized void updateBeaconCards() { iconContainer.setVisibility(GONE); } else { // Was always Apple's logo, for a Chipolo and an OpenHaystack tag alike. - iconContainer.setImageResource(BeaconIcon.forBeacon(beacon)); + BeaconIcon.applyTo(iconContainer, beacon); iconContainer.setVisibility(VISIBLE); emojiContainer.setVisibility(GONE); } @@ -1415,7 +1584,23 @@ private void fetchAndUpdateCurrentBeacons() { TagCardHelper.toggleRefreshLoadingAll(this.dynamicCardsForTag, true); var async = this.fetchLastReports(beacons) - .doOnNext(this::addBeaconLocationsToCurrent) + // **Drawn as each tag lands, not once at the end.** + // + // The fetch is one accessory at a time, and a tag with no key alignment record + // can take minutes on its own - so a batch of six is quarter of an hour during + // which nothing on screen moved, even though most of those answers arrived in + // the first few seconds. addBeaconLocationsToCurrent only updates the model; + // showLastDeviceLocations is what redraws, and it used to run in the terminal + // subscribe below, once, after the slowest tag. + // + // It is also what makes the fetch order worth anything: putting the tags that + // answer first is pointless if nothing is drawn until the silent ones have been + // ground through too. See ScanOrder. + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext(reports -> { + this.addBeaconLocationsToCurrent(reports); + this.showLastDeviceLocations(); + }) .flatMapCompletable((__) -> this.updateBeaconGeocodings()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(() -> { @@ -1451,7 +1636,11 @@ private Observable>> fetchLastReports(fin final int hoursToGoBack = this.refreshPolicy.hoursToGoBack(now); Log.d(TAG, "Preparing to fetch location reports for the last " + hoursToGoBack + " hours!"); - return this.beaconRepo.toAccessoryRequests(beaconIdToPlist) + // **The scheduled variant, and the only caller of it.** This overload is the periodic + // tick - it is the one that reads refreshPolicy for its window - so it is where tags + // that have gone quiet are allowed to be skipped. Every other fetch here is somebody + // asking, and asks about whatever it was given. + return this.beaconRepo.toScheduledAccessoryRequests(beaconIdToPlist) .doOnSubscribe(__ -> this.markFetchStarted()) .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack)) .doOnNext(reports -> this.refreshPolicy.markFetched(now)) // on success, update this time. @@ -1488,14 +1677,21 @@ private Observable>> fetchLastReports(fin private Observable>> fetchOneAccessoryAtATime( final List requests, final int hoursToGoBack) { - return RxFlows.oneAtATime( + // **A tag nobody has searched for yet gets the whole week.** Read once for the batch, + // then applied per accessory - see HOURS_TO_GO_BACK_FIRST_TIME. Everything else keeps + // the window it asked for. + return this.beaconRepo.neverScanned().flatMap(neverScanned -> RxFlows.oneAtATime( requests, - request -> this.appleService.getLastReports(List.of(request), hoursToGoBack) + request -> this.appleService.getLastReports( + List.of(request), + neverScanned.contains(request.getBeaconId()) + ? HOURS_TO_GO_BACK_FIRST_TIME + : hoursToGoBack) .flatMap(this.beaconRepo::storeFetchResult), this::setLongFetchProgress, (request, error) -> Log.e(TAG, "Failed to fetch reports for beaconId=" + request.getBeaconId() - + "; continuing with the remaining accessories", error)); + + "; continuing with the remaining accessories", error))); } private Observable>> fetchLastReportsFor(final String beaconId, final String pList, final int hoursToGoBack) { 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 9b72dc95..7d58c6ed 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java @@ -19,6 +19,7 @@ import androidx.activity.result.ActivityResult; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.databinding.DataBindingUtil; import androidx.recyclerview.widget.LinearLayoutManager; @@ -39,15 +40,19 @@ import dev.wander.android.opentagviewer.data.model.BeaconInformation; import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; import dev.wander.android.opentagviewer.databinding.ActivityMyDevicesListBinding; +import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; import dev.wander.android.opentagviewer.db.repo.BeaconRepository; +import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; import dev.wander.android.opentagviewer.ui.mydevices.DeviceListAdaptor; +import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; import dev.wander.android.opentagviewer.util.android.PropertiesUtil; import dev.wander.android.opentagviewer.util.export.HistoryZipWriter; import dev.wander.android.opentagviewer.util.parse.BeaconDataParser; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; public class MyDevicesListActivity extends AppCompatActivity { @@ -63,8 +68,31 @@ public class MyDevicesListActivity extends AppCompatActivity { private ActivityMyDevicesListBinding binding; + /** + * Whether anything changed here, so the map knows to re-read when this screen closes. + * + *

Saved and restored, because this screen recreates itself. Finishing an account + * read sets this and then calls {@code recreate()} to rebuild the list - which destroys the + * activity and constructs a new one, where a plain field is false again. The tags were on + * screen, the flag that says so was gone, and the map went on showing nothing until the app + * was restarted. See {@link #KEY_DEVICES_CHANGED}. + */ private boolean devicesListChanged = false; + /** Survives {@code recreate()}, which is the only reason this is in the instance state. */ + private static final String KEY_DEVICES_CHANGED = "devicesListChanged"; + + /** + * Whether this app has joined the account's keychain, once the store has said. + * + *

Null while that is still being read - a decryption on a background thread - and null is + * treated as "not linked" for the menu, which shows the item. See {@link #showPageMenu()}. + */ + private Boolean accountIsLinked; + + /** The in-flight read of that, so leaving does not land on a menu that has gone. */ + private Disposable membershipLookup; + /** * The tags whose history is being written, captured when the storage picker was opened. * @@ -86,6 +114,37 @@ public class MyDevicesListActivity extends AppCompatActivity { } ); + /** + * Reading the account, which can end by asking for the file picker instead. + * + *

An account with nothing to recover from, or with no tags on it, has one useful answer - + * import a bundle from somebody who owns them - so that screen hands the user straight back + * here with a flag rather than making them find the other button themselves. + */ + private final ActivityResultLauncher fetchFromICloudLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + (ActivityResult result) -> { + final Intent data = result.getData(); + if (data == null) { + return; + } + + if (data.getBooleanExtra( + FetchFromICloudActivity.RESULT_WANTS_FILE_IMPORT, false)) { + this.handleStartImport(); + return; + } + + if (data.getBooleanExtra(FetchFromICloudActivity.RESULT_IMPORTED, false)) { + // Rebuilt rather than appended to. This screen accumulates into `beaconInfo` + // on load, so fetching again would list everything twice - and the tags that + // just arrived have to appear without the user backing out and returning. + this.devicesListChanged = true; + this.recreate(); + } + } + ); + private final ActivityResultLauncher deviceInfoActivityLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), (ActivityResult result) -> { @@ -108,6 +167,11 @@ protected void onCreate(Bundle savedInstanceState) { this.beaconRepo = new BeaconRepository( OpenTagViewerDatabase.getInstance(getApplicationContext())); + if (savedInstanceState != null) { + this.devicesListChanged = + savedInstanceState.getBoolean(KEY_DEVICES_CHANGED, false); + } + this.binding = DataBindingUtil.setContentView(this, R.layout.activity_my_devices_list); WindowPaddingUtil.insertUITopPadding(this.binding.getRoot()); this.binding.setHandleClickBack(this::handleEndActivity); @@ -127,6 +191,7 @@ protected void onCreate(Bundle savedInstanceState) { this.binding.setHandleClickCloseSelection(this::endSelection); this.binding.setHandleClickSelectionMenu(this::showSelectionMenu); + this.binding.setHandleClickPageMenu(this::showPageMenu); this.showSelectionBar(false); RecyclerView recyclerView = findViewById(R.id.my_devices_list); @@ -136,6 +201,9 @@ protected void onCreate(Bundle savedInstanceState) { findViewById(R.id.my_devices_empty_import_button) .setOnClickListener(v -> this.handleStartImport()); + findViewById(R.id.my_devices_empty_fetch_button) + .setOnClickListener(v -> this.openTheAccountFetch()); + findViewById(R.id.my_devices_empty_wiki_link) .setOnClickListener(v -> this.openExportGuide()); @@ -153,9 +221,24 @@ public void handleOnBackPressed() { } }); + this.rememberWhetherTheAccountIsLinked(); this.fetchDeviceInfoAndRender(); } + @Override + protected void onDestroy() { + if (this.membershipLookup != null && !this.membershipLookup.isDisposed()) { + this.membershipLookup.dispose(); + } + super.onDestroy(); + } + + @Override + protected void onSaveInstanceState(@NonNull final Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean(KEY_DEVICES_CHANGED, this.devicesListChanged); + } + private void handleEndActivity() { Intent data = new Intent(); data.putExtra("isDeviceListChanged", this.devicesListChanged); @@ -199,6 +282,46 @@ private void refreshListOnBeaconChanged(final String beaconId) { } } + /** + * Re-read the last known locations whenever this screen comes back to the front. + * + *

The list was only ever loaded in {@code onCreate}. Coming back from the device + * page refreshed it only when that page said a device had been removed or changed, and + * fetching history is neither - so a tag whose locations had just been found through the + * history screen went on saying "No last location known" until something else recreated the + * activity. @parawanderer found it by going device → history → back and seeing no change, + * then reaching the same list through the map and seeing "3 days ago". The data had been + * there the whole time; this screen had not looked again. + * + *

Locations only. Rebuilding the beacons as well would re-sort and re-bind every row + * under somebody who is reading them, and their names and icons cannot change without the + * device page saying so - which it already does. + * + *

Skipped while the first load is still in flight, since {@code onResume} runs + * immediately after {@code onCreate} and there is nothing yet to refresh. + */ + @Override + protected void onResume() { + super.onResume(); + + if (this.beaconInfo.isEmpty()) { + return; + } + + var async = this.beaconRepo.getLastLocationsForAll() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(latest -> { + if (latest.equals(this.locations)) { + return; + } + + this.locations.clear(); + this.locations.putAll(latest); + this.deviceListAdaptor.notifyItemRangeChanged(0, this.beaconInfo.size()); + }, error -> Log.e(TAG, "Could not refresh the last known locations", error)); + } + private void fetchDeviceInfoAndRender() { var asyncLocations = this.beaconRepo.getLastLocationsForAll(); @@ -280,6 +403,61 @@ private void exportHistoryForSelection() { * a menu that grows an item between versions is harder to learn than one where the item is * visibly not ready. The XML disables it; this is where to stop doing that. */ + /** + * The two ways to get tags in, from a screen that already has some. + * + *

Both of these used to be reachable only from the empty state, which is hidden the + * moment anything is imported - so after a first import there was no way back to either. It + * matters most for the account route: the app joins the keychain precisely so that a later + * read costs one tap and no device passcode, and there was nothing to tap. + */ + private void showPageMenu() { + final PopupMenu menu = new PopupMenu( + this, this.binding.settingsTopToolbar.pageMenuButton); + menu.getMenuInflater().inflate(R.menu.my_devices_menu, menu.getMenu()); + + // **Hidden once the account is linked**, because linking is what this item does. After + // it, the app is a member of the keychain and re-reads without asking for anything, so + // an item offering to link again describes work already done. Shown while the answer is + // still unknown: the screen it leads to resumes as a member anyway, so the harmless + // mistake is offering it once too often rather than hiding the only way in. + menu.getMenu().findItem(R.id.action_fetch_from_account) + .setVisible(!Boolean.TRUE.equals(this.accountIsLinked)); + + menu.setOnMenuItemClickListener(item -> { + final int id = item.getItemId(); + + if (id == R.id.action_fetch_from_account) { + this.openTheAccountFetch(); + return true; + } + if (id == R.id.action_import_from_file) { + this.handleStartImport(); + return true; + } + return false; + }); + + menu.show(); + } + + private void rememberWhetherTheAccountIsLinked() { + this.membershipLookup = new KeychainMembershipRepository( + UserAuthDataStore.getInstance(this.getApplicationContext()), + new AppCryptographyUtil()) + .get() + .firstOrError() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + held -> this.accountIsLinked = held.isPresent(), + error -> Log.w(TAG, "Could not read whether the account is linked;" + + " the menu will go on offering to link it", error)); + } + + private void openTheAccountFetch() { + this.fetchFromICloudLauncher.launch(new Intent(this, FetchFromICloudActivity.class)); + } + private void showSelectionMenu() { PopupMenu menu = new PopupMenu(this, this.binding.selectionToolbar.selectionMenuButton); menu.getMenuInflater().inflate(R.menu.device_selection_menu, menu.getMenu()); @@ -353,20 +531,54 @@ private String suggestedZipName() { + ".zip"; } + /** + * Remove, minus the tags this app does not own. + * + *

A tag read from the Apple account cannot be removed from here, and saying so is the + * whole point of this. Marking one removed appears to work and then undoes itself: the + * row is a cache of the account, so the next refresh writes it back with + * {@code is_removed = 0} and the tag returns with no explanation. Removing it for real means + * removing it in Find My, which is the user's account to change and not this app's. + * + *

So the destructive button is offered only for what is actually the app's to remove, and + * a selection that is entirely account tags gets an explanation instead of a dialog whose + * confirm button would lie. + */ private void confirmRemoveSelection() { final List selected = this.deviceListAdaptor.getSelectedBeacons(); if (selected.isEmpty()) { return; } - new MaterialAlertDialogBuilder(this, com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) - .setTitle(selected.size() == 1 ? R.string.remove_device : R.string.remove_devices) - .setIcon(R.drawable.delete_24px) - .setMessage(selected.size() == 1 + final List removable = new ArrayList<>(); + int fromTheAccount = 0; + for (final BeaconInformation device : selected) { + if (device.isFromAccount()) { + fromTheAccount++; + } else { + removable.add(device); + } + } + + if (removable.isEmpty()) { + this.explainAccountTagsCannotBeRemoved(selected.size()); + return; + } + + // Mixed: the message names what will survive, so nobody has to notice afterwards that + // some of what they picked is still there. + final CharSequence message = fromTheAccount == 0 + ? this.getString(removable.size() == 1 ? R.string.are_you_sure_you_want_to_remove_this_device_once_removed_it_will_need_to_be_reimported_to_get_it_back : R.string.are_you_sure_you_want_to_remove_these_devices) + : this.getString(R.string.remove_devices_but_keep_account_ones, fromTheAccount); + + new MaterialAlertDialogBuilder(this, com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) + .setTitle(removable.size() == 1 ? R.string.remove_device : R.string.remove_devices) + .setIcon(R.drawable.delete_24px) + .setMessage(message) .setPositiveButton(R.string.confirm, (dialog, which) -> { - for (BeaconInformation device : selected) { + for (BeaconInformation device : removable) { this.removeDevice(device); } this.endSelection(); @@ -375,6 +587,20 @@ private void confirmRemoveSelection() { .show(); } + /** No confirm button, because there is nothing here for the app to do. */ + private void explainAccountTagsCannotBeRemoved(final int howMany) { + new MaterialAlertDialogBuilder(this, com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) + .setTitle(R.string.cannot_remove_account_tag_title) + // Not the delete icon. Nothing is being deleted, and the theme-tinted icons are + // the only ones legible in both modes - `apple.xml` is hardcoded black. + .setIcon(R.drawable.help_center_24px) + .setMessage(howMany == 1 + ? R.string.cannot_remove_account_tag_message + : R.string.cannot_remove_account_tags_message) + .setPositiveButton(R.string.ok, null) + .show(); + } + private void removeDevice(final BeaconInformation device) { final String beaconId = device.getBeaconId(); 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 1e9451ee..c5784f6b 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java @@ -61,6 +61,7 @@ import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; import dev.wander.android.opentagviewer.db.datastore.UserCacheDataStore; import dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore; +import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; import dev.wander.android.opentagviewer.db.repo.UserAuthRepository; import dev.wander.android.opentagviewer.db.repo.UserSettingsRepository; import dev.wander.android.opentagviewer.db.repo.model.UserAuthData; @@ -82,6 +83,7 @@ import dev.wander.android.opentagviewer.util.validate.AnisetteUrlValidatorUtil; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; import lombok.Data; import lombok.NonNull; @@ -115,6 +117,12 @@ public class SettingsActivity extends AppCompatActivity { private String initialAnisetteUrl = null; private boolean mapProviderChanged = false; + /** Reading whether the account is linked, so leaving does not land on dead views. */ + private Disposable membershipLookup; + + /** Forgetting the membership. Held for the same reason - it reports back on the main thread. */ + private Disposable unlinking; + @Override protected void onCreate(Bundle savedInstanceState) { @@ -146,6 +154,10 @@ protected void onCreate(Bundle savedInstanceState) { this.binding = DataBindingUtil.setContentView(this, R.layout.activity_settings); WindowPaddingUtil.insertUITopPadding(binding.getRoot()); this.binding.setHandleClickBack(this::handleEndActivity); + this.binding.setOnClickFetchFromAccount(this::onClickFetchFromAccount); + this.binding.setOnClickUnlinkAccount(this::onClickUnlinkAccount); + this.sayWhetherTheAccountIsLinked(); + this.binding.setOnClickTheme(this::onClickEditTheme); this.binding.setCurrentTheme(this.getCurrentThemeUiString()); this.binding.setOnClickLanguage(this::onClickEditLanguage); @@ -246,6 +258,122 @@ private String getCurrentThemeUiString() { : this.getString(R.string.light_theme); } + /** + * Say whether this app has already joined the account's keychain, and offer to undo it. + * + *

The row read the same either way, which is the wrong answer twice over: somebody + * who has linked cannot tell that they have, and somebody who has not is told their tags will + * "update" when nothing has ever been read. Being a member is the thing that makes a later + * read cost one tap and no device passcode, so it is worth saying out loud. + * + *

Set to the unlinked wording first and corrected when the store answers, rather than left + * blank until then. Reading it is a decryption on a background thread, and a row that appears + * with no subtitle and grows one a moment later is worse than one that starts by describing + * the more common case. + * + *

The unlink row follows the same answer. Hidden rather than disabled while unlinked - + * there is nothing to undo yet, and a permanently greyed row in Settings reads as something + * broken rather than something not applicable. + */ + private void sayWhetherTheAccountIsLinked() { + this.binding.setFetchFromAccountSubtitle( + this.getString(R.string.icloud_fetch_from_settings_subtitle)); + this.showTheUnlinkRow(false); + + this.membershipLookup = this.memberships().get() + .firstOrError() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + held -> { + this.binding.setFetchFromAccountSubtitle(this.getString( + held.isPresent() + ? R.string.icloud_fetch_from_settings_linked + : R.string.icloud_fetch_from_settings_subtitle)); + this.showTheUnlinkRow(held.isPresent()); + }, + error -> Log.w(TAG, "Could not read whether the account is linked;" + + " leaving the row describing the unlinked case", error)); + } + + private KeychainMembershipRepository memberships() { + return new KeychainMembershipRepository( + UserAuthDataStore.getInstance(this.getApplicationContext()), + new AppCryptographyUtil()); + } + + private void showTheUnlinkRow(final boolean visible) { + this.binding.settingsUnlinkAccount.getRoot() + .setVisibility(visible ? View.VISIBLE : View.GONE); + } + + /** + * Unlink, once the user has been told what that does and - more importantly - what it does + * not. + * + *

Nothing here can leave the account's trust circle, so the peer this app joined as + * goes on existing. All this does is forget the keys for it, which is what stops the + * background read. The entry the user can see in their Apple device list stays until they + * remove it there, and the dialog says so: somebody who unlinks expecting the device list to + * tidy itself up will otherwise go looking for a bug. + * + *

Confirmed rather than immediate because linking again is not free - it needs the Apple + * device passcode, which is not something people have to hand. + */ + private void onClickUnlinkAccount() { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.icloud_unlink_confirm_title) + .setMessage(R.string.icloud_unlink_confirm_message) + .setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss()) + .setPositiveButton(R.string.icloud_unlink_confirm_button, + (dialog, which) -> this.unlinkTheAccount()) + .show(); + } + + private void unlinkTheAccount() { + this.unlinking = this.memberships().forget() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + () -> { + Log.i(TAG, "The keychain membership has been forgotten"); + this.binding.setFetchFromAccountSubtitle(this.getString( + R.string.icloud_fetch_from_settings_subtitle)); + this.showTheUnlinkRow(false); + Toast.makeText(this, R.string.icloud_unlink_done, + Toast.LENGTH_LONG).show(); + }, + error -> { + // **Left linked, and said so.** The row is not hidden on failure: + // reporting an unlink that did not happen would leave the background + // read still running against an account the user believes it has let + // go of. + Log.e(TAG, "Could not forget the keychain membership", error); + Toast.makeText(this, R.string.icloud_unlink_failed, + Toast.LENGTH_LONG).show(); + }); + } + + @Override + protected void onDestroy() { + // The lookup hops back to the main thread to set a subtitle. If the screen has gone by + // then that is a binding update on detached views, so it is stopped rather than left to + // land wherever it lands. + if (this.membershipLookup != null && !this.membershipLookup.isDisposed()) { + this.membershipLookup.dispose(); + } + // **Disposed, but the write is not cancelled by it.** forget() is a DataStore update that + // has already been handed off; dropping the subscription only stops the toast and the + // binding update arriving at a screen that has gone. The membership is forgotten either + // way, which is the behaviour somebody who taps Unlink and immediately leaves expects. + if (this.unlinking != null && !this.unlinking.isDisposed()) { + this.unlinking.dispose(); + } + super.onDestroy(); + } + + private void onClickFetchFromAccount() { + this.startActivity(new Intent(this, FetchFromICloudActivity.class)); + } + private void onClickEditTheme() { final int currentOption = Optional.ofNullable(this.currentSettings.getUseDarkTheme()) .map(useDarkTheme -> useDarkTheme ? THEME_CHOICE_DARK : THEME_CHOICE_LIGHT) 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 fcc7acb8..ff8d70e8 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 @@ -218,6 +218,41 @@ public boolean isAirTag() { */ private final int customAccessoryKeyCount; + /** + * Whether this tag is a cache of the user's Apple account rather than the app's own copy. + * + *

It decides whether the app may remove it. A file-imported tag exists only here, + * so removing it is the user's decision and nobody else's. An account tag is a copy of what + * Apple holds, and the next refresh rewrites the row from the account - so "remove" would + * appear to work and then quietly undo itself. Removing one for real means removing it in + * Find My, which this app deliberately does not do on the user's behalf. + * + *

Set at construction from {@code OwnedBeacon.fromAccount}, like {@link #isCustomAccessory()} + * and unlike the heuristics on this class - there is nothing in a plist to infer it from. + */ + private final boolean fromAccount; + + /** + * When the app gave up looking for this tag, or null if it has not. + * + *

Set only after a search covering months of history found nothing anywhere - not merely + * after an empty search, which for a recently paired tag means very little. Shown on the + * device list in place of "no last location known", because those two states look identical + * and are not: one is a tag nobody has walked past this week, the other is a tag that has + * stopped broadcasting and is no longer being looked for. + */ + private final Long ignoredAt; + + /** Consecutive searches that found nothing, which is what paces the next one. */ + private final int fruitlessScans; + + /** When it was last searched for, or null if never. */ + private final Long lastScanAt; + + public boolean isIgnored() { + return this.ignoredAt != null; + } + public boolean isCustomAccessory() { return this.customAccessory; } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserAuthDataStore.java b/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserAuthDataStore.java index 12745600..5d6a6a95 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserAuthDataStore.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserAuthDataStore.java @@ -19,6 +19,15 @@ public final class UserAuthDataStore { public static final Preferences.Key APPLE_ACCOUNT = PreferencesKeys.byteArrayKey("apple_account"); + /** + * This app's membership of the user's keychain, encrypted under its own alias. + * + *

Beside the account rather than in the database: it is a secret, and this file is where + * secrets live. Deliberately not cleared by signing out - see the alias. + */ + public static final Preferences.Key KEYCHAIN_MEMBERSHIP = + PreferencesKeys.byteArrayKey("keychain_membership"); + public static RxDataStore getInstance(Context context) { if (AUTH_DATA_STORE == null) { AUTH_DATA_STORE = new RxPreferenceDataStoreBuilder(context, AUTH_FILE_FLENAME) 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 75063d34..e3da244b 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 @@ -5,6 +5,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; +import java.util.Set; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -24,8 +27,12 @@ import dev.wander.android.opentagviewer.python.AccessoryRequest; 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.rx.ScanOrder; +import dev.wander.android.opentagviewer.util.rx.WideScanBackoff; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.schedulers.Schedulers; @@ -36,6 +43,15 @@ public class BeaconRepository { private final OpenTagViewerDatabase db; private final PlistToAccessoryJsonConverter accessoryJsonConverter; + /** + * The source of the scheduled fetch's shuffle. + * + *

Held rather than made per call so a test can seed it, and so the sequence continues + * across refreshes instead of restarting - a fresh Random each time is a fresh chance to + * draw the same order. + */ + private final java.util.Random shuffle = new java.util.Random(); + public BeaconRepository(OpenTagViewerDatabase db) { this(db, new ChaquopyPlistToAccessoryJsonConverter()); } @@ -61,7 +77,23 @@ public Observable addNewImport(@NonNull ImportData importData) throw var ownedBeacons = importData.getOwnedBeacons(); ownedBeacons.forEach(b -> b.importId = insertionId); - db.ownedBeaconDao().insertAll(ownedBeacons.toArray(new OwnedBeacon[0])); + + // **Insert what is new, update what is held.** Re-importing is ordinary - a newer + // export carries a key alignment record an older one lacked - and a re-insert + // would delete the existing row, cascading into the user's custom names, the + // tag's location history and the record of which days have been fetched. See + // OwnedBeaconDao#insertAll. + db.ownedBeaconDao().insertIfNew(ownedBeacons.toArray(new OwnedBeacon[0])); + + for (final OwnedBeacon beacon : ownedBeacons) { + db.ownedBeaconDao().refreshFromImport( + beacon.id, + beacon.content, + beacon.alignmentPlist, + beacon.accessoryJson, + beacon.version, + insertionId); + } var beaconNamingRecords = importData.getBeaconNamingRecords(); beaconNamingRecords.forEach(b -> b.importId = insertionId); @@ -75,6 +107,139 @@ public Observable addNewImport(@NonNull ImportData importData) throw }).subscribeOn(Schedulers.io()); } + /** + * Bring the beacons held for the Apple account into line with what it actually holds. + * + *

A refresh, not an import. These rows are a cache of somebody's account: what is + * on it is written, and what has left it is retired. That is the whole reason + * {@code from_account} exists - a file-imported beacon is the only copy anyone has, so this + * must never touch one, and the DAO scopes every write accordingly. + * + *

Retired rather than deleted, so a tag that leaves the account does not take its location + * history with it on the way out. + * + *

Rows are written with {@code is_removed = 0}, which matters for a tag that left the + * account and later came back: without it the row would be restored still marked as gone. + * + * @return the ids now held for the account. + */ + public Observable> refreshAccountBeacons( + @NonNull final List fromAccount) { + return Observable.fromCallable(() -> { + try { + final List ids = new ArrayList<>(); + final List beacons = new ArrayList<>(); + final List namingRecords = new ArrayList<>(); + + for (final AccessoryRecords record : fromAccount) { + ids.add(record.getBeaconId()); + + beacons.add(OwnedBeacon.builder() + .id(record.getBeaconId()) + .importId(null) + .version(ACCOUNT_SOURCED_VERSION) + .content(record.getOwnedBeaconPlist()) + .alignmentPlist(record.getKeyAlignmentPlist()) + // Converted eagerly, exactly as a zip import does. Null on failure is + // not fatal: the lazy backfill on first fetch handles it. + .accessoryJson(this.accessoryJsonConverter.convert( + record.getOwnedBeaconPlist(), record.getKeyAlignmentPlist())) + .fromAccount(true) + .isRemoved(false) + .build()); + + if (record.getNamingRecordPlist() != null) { + namingRecords.add(BeaconNamingRecord.builder() + .id(record.getBeaconId()) + .importId(null) + .version(ACCOUNT_SOURCED_VERSION) + .content(record.getNamingRecordPlist()) + .build()); + } + } + + // **Insert the new ones, update the rest. Never re-insert.** A re-insert is + // `INSERT OR REPLACE`, which deletes the row it is replacing and cascades that + // delete into UserBeaconOptions and LocationReport - so an ordinary background + // read would silently take the user's custom names and the tag's whole location + // history with it. See OwnedBeaconDao#insertAll. + if (!beacons.isEmpty()) { + db.ownedBeaconDao().insertIfNew(beacons.toArray(new OwnedBeacon[0])); + + for (final OwnedBeacon beacon : beacons) { + db.ownedBeaconDao().refreshFromAccount( + beacon.id, + beacon.content, + beacon.alignmentPlist, + beacon.accessoryJson, + beacon.version); + } + } + if (!namingRecords.isEmpty()) { + db.beaconNamingRecordDao() + .insertAll(namingRecords.toArray(new BeaconNamingRecord[0])); + } + + // `NOT IN ()` is not valid SQL, so an account that now holds nothing needs the + // other query rather than a list nobody can match against. + final int retired = ids.isEmpty() + ? db.ownedBeaconDao().retireEveryAccountBeacon() + : db.ownedBeaconDao().retireAccountBeaconsMissingFrom(ids); + + Log.i(TAG, "Refreshed from the Apple account: " + ids.size() + + " held, " + retired + " retired"); + + return ids; + } catch (Exception e) { + Log.e(TAG, "Error occurred while refreshing the beacons held for the account", e); + throw new RepoQueryException(e); + } + }).subscribeOn(Schedulers.io()); + } + + /** + * Write a name and emoji that iCloud has already accepted into the stored naming record. + * + *

Only after the account has taken the change, never before and never instead. This + * is what makes the tag's real name change rather than acquiring a nickname over the top of + * it - see {@link dev.wander.android.opentagviewer.util.parse.NamingRecordEditor} for why the + * difference is not cosmetic. + * + *

Silently does nothing for a tag with no naming record. CloudKit holds none for an + * accessory nobody ever named, and a rename of one of those is a change the next account read + * will bring back properly - there is nothing here to edit in the meantime, and inventing a + * record would put a document in the database that Apple never sent. + */ + public Completable renameStoredAccessory( + @NonNull final String beaconId, final String name, final String emoji) { + return Completable.fromAction(() -> { + // **Any nickname over this tag has to go.** The name being written is now the tag's + // real one, and an override wins at display time - leaving one would hide the value + // that was just sent to Apple behind the value it replaced. + db.userBeaconOptionsDao().deleteById(beaconId); + + final BeaconNamingRecord stored = db.beaconNamingRecordDao().getByBeaconId(beaconId); + + if (stored == null) { + Log.i(TAG, "No stored naming record for " + beaconId + + "; the next account read will bring the new name back"); + return; + } + + stored.content = NamingRecordEditor.with(stored.content, name, emoji); + db.beaconNamingRecordDao().insertAll(stored); + }).subscribeOn(Schedulers.io()); + } + + /** + * The {@code version} recorded for a row that came from the account rather than a bundle. + * + *

A bundle's version is its export format, which is what tells a reader how to interpret + * the files in it. Nothing was exported here, so borrowing a format number would be a claim + * about a file that does not exist. + */ + private static final String ACCOUNT_SOURCED_VERSION = "account"; + public Observable> getImportById(final long importId) { return Observable.fromCallable(() -> { try { @@ -186,6 +351,92 @@ public static Map plistFallbacks(final List beacons * {@link java.util.HashMap}, because {@code Map.of} and * {@code Collectors.toMap} both throw on a null value. */ + /** + * The same, minus the tags a scheduled fetch should leave alone right now. + * + *

A separate entry point on purpose, and the separation is the safety. The backoff + * exists so the app stops spending most of its conversation with Apple on tags that never + * answer - but a person who opens a tag and presses refresh must get a search every time, + * however long it has been quiet, because they may have just found the thing. Filtering + * inside {@link #toAccessoryRequests} would have applied it to both, and the user-facing + * failure would be a button that silently does nothing. + * + *

So the periodic path calls this and the manual paths call the other one, and which is + * which is readable at the call site rather than hidden behind a flag. + */ + /** The newest location held for a tag, for the "last result" line on the tag page. */ + public Observable> newestReportTimeFor(@NonNull final String beaconId) { + return Observable.fromCallable( + () -> Optional.ofNullable(db.locationReportDao().newestReportTimeFor(beaconId))) + .subscribeOn(Schedulers.io()); + } + + public Observable> toScheduledAccessoryRequests( + final Map beaconIdToPlistFallback) { + + return Observable.fromCallable(() -> this.dueForAScheduledScan(beaconIdToPlistFallback)) + .subscribeOn(Schedulers.io()) + .flatMap(this::toAccessoryRequests); + } + + private Map dueForAScheduledScan(final Map all) { + final long now = System.currentTimeMillis(); + final var dao = db.ownedBeaconDao(); + + final List candidates = new ArrayList<>(); + int ignored = 0; + int waiting = 0; + + for (final var entry : all.entrySet()) { + final OwnedBeacon row = dao.getById(entry.getKey()); + + if (row != null && row.ignoredAt != null) { + ignored++; + continue; + } + if (row != null && !WideScanBackoff.isDue(now, row.fruitlessScans, row.lastScanAt)) { + waiting++; + continue; + } + + candidates.add(new ScanOrder.Candidate( + entry.getKey(), + row != null && row.lastScanAt != null, + row != null && row.lastScanAt != null && row.fruitlessScans == 0)); + } + + if (ignored > 0 || waiting > 0) { + Log.d(TAG, "Scheduled fetch is skipping " + ignored + " ignored and " + waiting + + " backing off; asking about " + candidates.size()); + } + + // **A LinkedHashMap, because the order is the point.** toAccessoryRequests walks the map + // it is handed, so a HashMap here would throw the ordering away silently - the requests + // would come out in hash order and nothing would fail. + // + // Null values are meaningful - see toAccessoryRequests - so every key goes in with + // whatever fallback it had. + final Map due = new LinkedHashMap<>(); + for (final String beaconId : ScanOrder.forScheduledFetch(candidates, this.shuffle)) { + due.put(beaconId, all.get(beaconId)); + } + + return due; + } + + /** + * The beacons nobody has ever searched for, which get a wider first window. + * + *

Read once per batch rather than per accessory: it is one small query and the answer + * cannot change underneath a batch in a way that matters - a tag that becomes scanned + * halfway through simply gets the ordinary window next time, which is the intent. + */ + public Observable> neverScanned() { + return Observable.fromCallable( + () -> (Set) new HashSet<>(db.ownedBeaconDao().neverScannedIds())) + .subscribeOn(Schedulers.io()); + } + public Observable> toAccessoryRequests(Map beaconIdToPlistFallback) { return Observable.fromCallable(() -> { if (beaconIdToPlistFallback.isEmpty()) { @@ -245,11 +496,81 @@ public Observable>> storeFetchResult(Fetc dao.updateAccessoryJson(entry.getKey(), entry.getValue()); } } + + this.recordWhatEachScanFound(dao, fetchResult); + return fetchResult.getReports(); }).subscribeOn(Schedulers.io()) .flatMap(this::storeToLocationCache); } + /** + * Note, per tag, whether this search found anything - which is what paces the next one. + * + *

Three outcomes, not two. Something found resets everything. Nothing found lengthens + * the wait a little, because a fortnight of silence is an ordinary tag having an ordinary + * week. Nothing found across months of history is different in kind: the tag has + * stopped broadcasting, and every further search costs a full-history scan that cannot repay + * itself, so it is set aside until somebody asks. + * + *

Driven from what was actually searched rather than from a count of reports, because only + * Python knows how wide the key window was - see {@code FetchResult#getExhaustedWideSearch}. + */ + private void recordWhatEachScanFound( + final dev.wander.android.opentagviewer.db.room.dao.OwnedBeaconDao dao, + final FetchResult fetchResult) { + + final long now = System.currentTimeMillis(); + + for (final var entry : fetchResult.getReports().entrySet()) { + final String beaconId = entry.getKey(); + final boolean foundSomething = entry.getValue() != null && !entry.getValue().isEmpty(); + + if (foundSomething) { + dao.recordSuccessfulScan(beaconId, now); + } else if (fetchResult.getExhaustedWideSearch().contains(beaconId)) { + // **Not on the first one.** Retiring a tag is close to permanent - it is skipped + // by every automatic fetch afterwards and only comes back when somebody opens it + // and asks - so one bad search is a thin basis for it. A fetch can come back + // empty for reasons that have nothing to do with the tag: a request that failed, + // an account that was briefly unhappy, a moment when Apple returned nothing. + // + // So it takes a second, and the two are a refresh cycle apart rather than + // back-to-back, which is a real further chance for somebody to walk past it. + // + // Phrased as "the previous search also failed" rather than "was also + // exhaustive", because there is no column recording that and adding one means a + // Room migration - rule 1 - for a refinement of a heuristic. In practice the + // difference is nothing: exhaustion needs a key window wider than + // _DEAD_TAG_WIDTH_INDICES, and a window that wide does not narrow on its own, so + // for the tags this is aimed at every consecutive failure is an exhaustive one. + final OwnedBeacon held = dao.getById(beaconId); + final int failuresBefore = held == null ? 0 : held.fruitlessScans; + + if (failuresBefore >= 1) { + Log.i(TAG, beaconId + " found nothing across months of history for the" + + " second time running; it will be skipped until somebody asks for" + + " it directly"); + dao.markIgnored(beaconId, now); + } else { + Log.i(TAG, beaconId + " found nothing across months of history; giving it" + + " one more search before setting it aside"); + dao.recordFruitlessScan(beaconId, now); + } + } else if (fetchResult.getWideSearch().contains(beaconId)) { + dao.recordFruitlessScan(beaconId, now); + } else { + // **An empty answer from a cheap search is not a failure.** An aligned tag costs + // a request or two, and finding nothing new in the window asked for is the + // ordinary state of a tag that reported an hour ago and has not moved since. + // Counting it made tags that update every day slowly accrue strikes and start + // being asked less often - the opposite of what the backoff is for, which is to + // stop full-history searches nobody is going to benefit from. + dao.recordSuccessfulScan(beaconId, now); + } + } + } + public Observable>> storeToLocationCache(Map> reportsForBeaconId) { return Observable.fromCallable(() -> { if (reportsForBeaconId.isEmpty()) { 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 new file mode 100644 index 00000000..c2a66edf --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/KeychainMembershipRepository.java @@ -0,0 +1,139 @@ +package dev.wander.android.opentagviewer.db.repo; + +import static dev.wander.android.opentagviewer.AppKeyStoreConstants.KEYSTORE_ALIAS_KEYCHAIN; +import static dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore.KEYCHAIN_MEMBERSHIP; + +import android.util.Log; + +import androidx.datastore.preferences.core.MutablePreferences; +import androidx.datastore.preferences.core.Preferences; +import androidx.datastore.rxjava3.RxDataStore; + +import org.json.JSONObject; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import dev.wander.android.opentagviewer.python.icloud.KeychainMembership; +import dev.wander.android.opentagviewer.db.AppCryptographyException; +import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.core.Single; +import lombok.NonNull; + +/** + * Where this app's membership of the user's keychain is kept. + * + *

Encrypted, under its own keystore alias. It holds a circle member's private keys - + * FindMy.py calls serialising them "the most sensitive thing the library writes" and does it in + * the clear - so this is the same treatment the Apple session gets, and for a stronger reason. + * Its own alias rather than the account's because they have different lifetimes: signing out + * clears the session and must not silently strand the peer. + * + *

It also holds the escrow passcode. That is a secret this app generated and nobody has ever + * seen, kept only so the peer can be recovered through escrow if this store is destroyed - which + * is exactly the situation where it will not be available, so it is a fallback for the + * account outliving the app rather than for the app losing its data. + */ +public class KeychainMembershipRepository { + private static final String TAG = KeychainMembershipRepository.class.getSimpleName(); + + private static final String FIELD_PEER = "peer"; + private static final String FIELD_ENTROPY = "entropy"; + private static final String FIELD_PASSCODE = "escrowPasscode"; + private static final String FIELD_LABEL = "label"; + private static final String FIELD_SHARES = "shares"; + + private final RxDataStore store; + private final AppCryptographyUtil cryptography; + + public KeychainMembershipRepository( + @NonNull final RxDataStore store, + @NonNull final AppCryptographyUtil cryptography) { + this.store = store; + this.cryptography = cryptography; + } + + /** 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(preferences -> { + final byte[] encrypted = preferences.get(KEYCHAIN_MEMBERSHIP); + if (encrypted == null) { + return Optional.empty(); + } + + 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(); + } + }); + } + + /** + * Store a membership, and refuse to report success unless it is really stored. + * + *

This write is the one that must not be lost. By the time it runs, the join has + * already happened on Apple's side: a peer exists on the user's account whether or not this + * succeeds, and these keys are the only copy of the means to use it. A silent failure here + * leaves them with a stranded peer and this app none the wiser. + */ + public Completable store(@NonNull final KeychainMembership membership) { + return Single.fromCallable(() -> { + final JSONObject json = new JSONObject() + .put(FIELD_PEER, membership.getPeerJson()) + .put(FIELD_ENTROPY, membership.getEntropy()) + .put(FIELD_PASSCODE, membership.getEscrowPasscode()) + .put(FIELD_LABEL, membership.getLabel()) + .put(FIELD_SHARES, membership.getShares()); + + final var encrypted = this.cryptography.encrypt( + json.toString().getBytes(StandardCharsets.UTF_8), KEYSTORE_ALIAS_KEYCHAIN); + + if (encrypted.getIv().length != AppCryptographyUtil.EXPECTED_IV_SIZE) { + throw new AppCryptographyException( + "Unexpected IV size " + encrypted.getIv().length + + " when encrypting the keychain membership"); + } + + return encrypted.flatten(); + }).flatMapCompletable(flattened -> Completable.fromSingle( + this.store.updateDataAsync(preferences -> { + final MutablePreferences mutable = preferences.toMutablePreferences(); + mutable.set(KEYCHAIN_MEMBERSHIP, flattened); + return Single.just(mutable); + }))); + } + + /** + * Forget the membership. + * + *

This does not leave the circle, and nothing here can. It only makes this app stop + * using a peer that still exists on the account - which is the right thing when the stored + * keys have stopped working, and the wrong thing to reach for casually, because afterwards + * the peer is unreachable from here. + */ + public Completable forget() { + return Completable.fromSingle(this.store.updateDataAsync(preferences -> { + final MutablePreferences mutable = preferences.toMutablePreferences(); + mutable.remove(KEYCHAIN_MEMBERSHIP); + return Single.just(mutable); + })); + } +} 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 adf69530..ce83ee85 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 @@ -31,7 +31,7 @@ DailyHistoryFetchRecord.class, UserBeaconOptions.class }, - version = 3 + version = 5 ) public abstract class OpenTagViewerDatabase extends RoomDatabase { private static OpenTagViewerDatabase INSTANCE = null; @@ -63,6 +63,49 @@ public void migrate(@NonNull SupportSQLiteDatabase db) { } }; + /** + * v3 → v4: adds {@code from_account} to {@code OwnedBeacons}, marking a beacon as read from + * the user's Apple account rather than imported from a file. + * + *

The two are not the same kind of row and must not be treated alike. An account + * beacon is a cache: the list is refreshed from Apple, so one that has left the account is + * removed here too. A file-imported beacon is the only copy that exists - nobody else holds + * it, and the export it came from may be long gone - so a refresh must never touch it. Without + * a way to tell them apart, "drop what is no longer on the account" would delete every + * imported tag the first time somebody fetched. + * + *

Pure additive ALTER with a default of 0, which is right for every existing row: they all + * predate the account route and every one of them came from a file. + */ + public static final Migration MIGRATION_3_4 = new Migration(3, 4) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL( + "ALTER TABLE OwnedBeacons ADD COLUMN from_account INTEGER NOT NULL DEFAULT 0"); + } + }; + + /** + * v4 → v5: three columns on {@code OwnedBeacons} for tags that have stopped broadcasting. + * + *

A tag with no key alignment record searches its whole life on every refresh, at a + * request per ~290 keys. For a tag that will never answer that is the most expensive thing + * in the batch, repeated forever - so {@code fruitless_scans} and {@code last_scan_at} drive + * a backoff, and {@code ignored_at} marks the ones given up on entirely. + * + *

Additive, with defaults that mean "healthy, never scanned, not ignored" - so every + * existing row keeps behaving exactly as it did until its first fruitless search. + */ + public static final Migration MIGRATION_4_5 = new Migration(4, 5) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE OwnedBeacons" + + " ADD COLUMN fruitless_scans INTEGER NOT NULL DEFAULT 0"); + db.execSQL("ALTER TABLE OwnedBeacons ADD COLUMN last_scan_at INTEGER"); + db.execSQL("ALTER TABLE OwnedBeacons ADD COLUMN ignored_at 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 @@ -78,7 +121,7 @@ public static OpenTagViewerDatabase getInstance(Context context) { context, OpenTagViewerDatabase.class, DATABASE_NAME) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) .build(); } 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 9a1c0978..d8d6d9fb 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 @@ -20,6 +20,16 @@ public interface LocationReportDao { @Query("SELECT MAX(timestamp) AS latest_report_timestamp, * FROM LocationReport GROUP BY beacon_id") List getLastForAllBeacons(); + /** + * The newest report held for one tag, or null if there are none. + * + *

Read for the tag page's debug line: "last result" answers a different question from + * "last attempt", and the gap between them is the whole diagnosis when somebody asks why a + * tag has stopped moving. + */ + @Query("SELECT MAX(timestamp) FROM LocationReport WHERE beacon_id = :beaconId") + Long newestReportTimeFor(String beaconId); + @Insert(onConflict = OnConflictStrategy.REPLACE) void insertAll(LocationReport... locationReports); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/OwnedBeaconDao.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/OwnedBeaconDao.java index 35f1f483..e7ef6576 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/OwnedBeaconDao.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/OwnedBeaconDao.java @@ -21,9 +21,85 @@ public interface OwnedBeaconDao { @Query("SELECT * FROM OwnedBeacons WHERE id = :beaconId AND is_removed = 0") OwnedBeacon getById(String beaconId); + /** + * REPLACE deletes the existing row, and that delete cascades. + * + *

SQLite's {@code INSERT OR REPLACE} is not an update: on a primary key conflict it removes + * the old row and inserts a new one. Room turns foreign keys on, so that removal runs every + * {@code ON DELETE CASCADE} hanging off {@code OwnedBeacons} - and both + * {@code UserBeaconOptions} and {@code LocationReport} are children. Re-writing a beacon that + * already exists therefore erases the user's custom name and emoji for it and its entire + * location history, while reading at the call site exactly like an upsert. + * + *

So this is for rows that are genuinely new. Anything that re-writes a beacon the database + * may already hold wants {@link #insertIfNew} and {@link #refreshFromAccount} instead - see + * {@code AccountRefreshKeepsWhatTheUserOwnsTest}, which exists because this went unnoticed. + */ @Insert(onConflict = OnConflictStrategy.REPLACE) List insertAll(OwnedBeacon... ownedBeacons); + /** Add beacons that are not held yet, leaving any that are exactly as they are. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + List insertIfNew(OwnedBeacon... ownedBeacons); + + /** + * Bring a held beacon into line with what the account says, without touching anything else. + * + *

An UPDATE rather than a re-insert, so nothing cascades. Three of the columns are written + * defensively, and each {@code COALESCE} is deliberate: + * + *

    + *
  • {@code accessory_json} keeps what is already there. It is not a copy of the + * plist - it carries the rolling-key alignment state that {@link #updateAccessoryJson} + * maintains after every fetch. Overwriting it with a freshly converted one throws that + * away and sends the next fetch back to searching the tag's whole history, which is the + * expense the alignment record exists to avoid. It is only filled in when absent.
  • + *
  • {@code alignment_plist} prefers the account's copy, which is authoritative, but + * will not be cleared by a read that happens not to carry one.
  • + *
  • {@code content} likewise. A null plist means this read did not include it, not + * that the tag no longer has one.
  • + *
+ * + *

{@code is_removed} is cleared because a tag that left the account and came back should + * not be restored still marked as gone. + */ + @Query("UPDATE OwnedBeacons SET" + + " content = COALESCE(:content, content)," + + " alignment_plist = COALESCE(:alignmentPlist, alignment_plist)," + + " accessory_json = COALESCE(accessory_json, :accessoryJson)," + + " version = :version," + + " import_id = NULL," + + " from_account = 1," + + " is_removed = 0" + + " WHERE id = :beaconId") + void refreshFromAccount(String beaconId, String content, String alignmentPlist, + String accessoryJson, String version); + + /** + * The same, for a beacon arriving again in a newer zip. + * + *

Re-importing an export is an ordinary thing to do - a newer one carries a key alignment + * record an older one lacked - and it must not cost the user their custom names, the tag's + * location history, or the record of which days have already been fetched. All three are + * children of this table and all three cascade, so this re-links the import without deleting + * anything. The {@code COALESCE} choices are the same as {@link #refreshFromAccount}. + * + *

{@code from_account} is cleared, matching what a re-insert did: a beacon that arrived in + * a file is a file-imported beacon, and must not then be retired by an account refresh that + * does not list it. + */ + @Query("UPDATE OwnedBeacons SET" + + " content = COALESCE(:content, content)," + + " alignment_plist = COALESCE(:alignmentPlist, alignment_plist)," + + " accessory_json = COALESCE(accessory_json, :accessoryJson)," + + " version = :version," + + " import_id = :importId," + + " from_account = 0," + + " is_removed = 0" + + " WHERE id = :beaconId") + void refreshFromImport(String beaconId, String content, String alignmentPlist, + String accessoryJson, String version, long importId); + @Query("UPDATE OwnedBeacons SET is_removed = 1 WHERE id = :beaconId") void setRemoved(String beaconId); @@ -37,4 +113,65 @@ public interface OwnedBeaconDao { @Delete void delete(OwnedBeacon ownedBeaconWithId); + + /** + * Record that a search for this tag found something. + * + *

Clears everything held against it: a tag that reports is a normal tag, whatever it was + * doing before. A coat comes out of storage, a bike is found, and the app must go straight + * back to asking about it as often as any other. + */ + @Query("UPDATE OwnedBeacons SET fruitless_scans = 0, last_scan_at = :at, ignored_at = NULL" + + " WHERE id = :beaconId") + void recordSuccessfulScan(String beaconId, long at); + + /** Record that a search found nothing, which lengthens the wait before the next one. */ + @Query("UPDATE OwnedBeacons SET fruitless_scans = fruitless_scans + 1, last_scan_at = :at" + + " WHERE id = :beaconId") + void recordFruitlessScan(String beaconId, long at); + + /** + * Give up on this tag until somebody asks again. + * + *

Only for a search that covered months and found nothing anywhere - see + * {@code _DEAD_TAG_WIDTH_INDICES}. An ignored tag is skipped by the automatic fetches + * entirely, which is the point: each one costs a full-history search that will not repay it. + */ + @Query("UPDATE OwnedBeacons SET ignored_at = :at, last_scan_at = :at," + + " fruitless_scans = fruitless_scans + 1 WHERE id = :beaconId") + void markIgnored(String beaconId, long at); + + /** + * The beacons nobody has ever searched for. + * + *

They get a wider first window than everything else - see + * {@code MapsActivity#HOURS_TO_GO_BACK_FIRST_TIME}. A tag arriving from a zip or from the + * account has no reports at all yet, and asking only about the last day means one that was + * last seen on Tuesday shows "No last location known" on a screen that has never looked + * further back than this morning. + */ + @Query("SELECT id FROM OwnedBeacons WHERE last_scan_at IS NULL AND is_removed = 0") + List neverScannedIds(); + + /** The beacons currently held as a cache of the Apple account. */ + @Query("SELECT id FROM OwnedBeacons WHERE from_account = 1 AND is_removed = 0") + List getAccountBeaconIds(); + + /** + * Retire the account beacons that are no longer on the account. + * + *

Scoped to {@code from_account = 1}, and that scope is load-bearing. A + * file-imported beacon is the only copy in existence - nobody else holds it and the export it + * came from may be long gone - so a refresh of what Apple holds must never reach one. + * + *

Marked removed rather than deleted, because {@code LocationReport} cascades on delete + * and a tag that leaves the account should not take its history with it. + */ + @Query("UPDATE OwnedBeacons SET is_removed = 1" + + " WHERE from_account = 1 AND id NOT IN (:stillOnTheAccount)") + int retireAccountBeaconsMissingFrom(List stillOnTheAccount); + + /** The same, for an account that now holds nothing - `NOT IN ()` is not valid SQL. */ + @Query("UPDATE OwnedBeacons SET is_removed = 1 WHERE from_account = 1") + int retireEveryAccountBeacon(); } 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 785fd283..684b563b 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 @@ -19,4 +19,14 @@ public interface UserBeaconOptionsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) void insertAll(UserBeaconOptions... options); + + /** + * Drop one tag's overrides. + * + *

What makes a real rename real. An accessory renamed in iCloud has its actual name + * changed, so any nickname sitting over the top of it has to go - a leftover would win at + * display time and hide the value that was just written. + */ + @Query("DELETE FROM UserBeaconOptions WHERE beacon_id = :beaconId") + void deleteById(String beaconId); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/OwnedBeacon.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/OwnedBeacon.java index 9fb21e7c..2e1f9fe9 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/OwnedBeacon.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/OwnedBeacon.java @@ -46,6 +46,50 @@ public class OwnedBeacon { @ColumnInfo(name = "is_removed") public boolean isRemoved; + /** + * Whether this beacon was read from the user's Apple account rather than imported from a file. + * + *

The two are different kinds of row, and the difference decides what may delete them. + * An account beacon is a cache of what Apple holds: the list is re-read, so one that has left + * the account goes from here too. A file-imported beacon is the only copy in existence - + * nobody else has it and the export it came from may be long gone - so a refresh must never + * touch it. + * + *

False for every row that predates this, which is correct: they all came from a file. + */ + @ColumnInfo(name = "from_account") + public boolean fromAccount; + + /** + * How many times in a row a full-history search for this tag has come back with nothing. + * + *

What stops the app scanning a silent tag every single refresh. A tag with no key + * alignment record searches from its pairing date, and that search costs a request per ~290 + * keys - so a tag nobody has walked past is not merely uninformative, it is the most + * expensive thing in the batch, repeated on every tick. The count drives a backoff: the + * longer it has said nothing, the less often it is asked. See {@code WideScanBackoff}. + * + *

Reset to zero the moment anything is found, because a tag that reports again is a + * normal tag again - the silence may have been a fortnight in a drawer. + */ + @ColumnInfo(name = "fruitless_scans", defaultValue = "0") + public int fruitlessScans; + + /** When this tag was last searched for, so the backoff knows whether it is due. */ + @ColumnInfo(name = "last_scan_at") + public Long lastScanAt; + + /** + * When the app gave up on this tag, or null if it has not. + * + *

Set only when a search that covered months of history found nothing anywhere - + * not merely when a search found nothing, which for a young tag means almost nothing. Such a + * tag is skipped entirely rather than backed off, and says so on screen with a button to try + * again; anything found clears this. + */ + @ColumnInfo(name = "ignored_at") + public Long ignoredAt; + /** * Serialized FindMyAccessory state (JSON) for FindMy.py 0.9.x. Includes the * rolling-key alignment that updates after every fetch — persisting it back 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 b04f7505..48cbafc1 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 @@ -7,9 +7,12 @@ import androidx.annotation.VisibleForTesting; 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; @@ -67,6 +70,44 @@ public interface AnisetteFactory { */ private static HardwareDescriber hardwareDescriber = new ChaquopyHardwareDescriber(); + /** + * 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; } @@ -111,5 +152,6 @@ public static void reset() { anisetteFactory = LocalAnisette::new; serverTesterFactory = AnisetteServerTesterService::new; hardwareDescriber = new ChaquopyHardwareDescriber(); + icloudFactory = AppDependencies::openRealICloud; } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyHardwareDescriber.java b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyHardwareDescriber.java index 5168f164..0e785ad7 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyHardwareDescriber.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyHardwareDescriber.java @@ -29,6 +29,14 @@ public String whereToLookUp(final String plistXml) { return call("whereToLookUpHardware", plistXml); } + @Override + public Boolean isOwnDevice(final String plistXml) { + // Parsed from the same string channel the other two use rather than crossing a boolean, + // so a failure arrives as null - "not established" - instead of as a confident false. + final String answered = call("isOwnDeviceHardware", plistXml); + return answered == null ? null : Boolean.valueOf("True".equals(answered)); + } + /** *

A null or empty plist short-circuits rather than crossing the bridge. A self-generated * tag has no plist at all, and the Python side would only decode the empty string and return diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/FetchResult.java b/app/src/main/java/dev/wander/android/opentagviewer/python/FetchResult.java index eaf31571..b86711d2 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/FetchResult.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/FetchResult.java @@ -19,4 +19,25 @@ public class FetchResult { private final Map> reports; private final Map updatedAccessoryJson; + + /** + * The accessories whose search covered months of history and found nothing at all. + * + *

Not the same as "no reports". A tag with no key alignment record searches from + * its pairing date, so a young one searches a small range and an empty answer means very + * little - it may not have been near an iPhone this week. Only a wide search that stayed + * wide says the tag has been silent for months, and Python is where the width is known. + */ + private final java.util.Set exhaustedWideSearch; + + /** + * The accessories whose search was an expensive one - a wide key window. + * + *

What separates "nothing new" from "nothing at all". An aligned tag costs a + * request or two and an empty answer means only that it has not moved since the window + * began, which is the ordinary state of a tag that reported an hour ago. Counting that as a + * failure made healthy tags accrue strikes and start being asked less often, which is the + * opposite of what the backoff is for. + */ + private final java.util.Set wideSearch; } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/HardwareDescriber.java b/app/src/main/java/dev/wander/android/opentagviewer/python/HardwareDescriber.java index b49d3aba..185cd413 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/HardwareDescriber.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/HardwareDescriber.java @@ -36,4 +36,24 @@ public interface HardwareDescriber { * there is nothing worth saying - which is the common case. */ String whereToLookUp(String plistXml); + + /** + * Whether this is one of the owner's own devices rather than an accessory. + * + *

What decides whether renaming writes to iCloud or stays a local nickname. An + * AirTag or a Find My-certified tag keeps its name in the naming record and nowhere else, so + * writing that record is the whole rename. An iPhone, iPad or Mac takes its name from more + * places, so writing it would leave Find My disagreeing with the device itself. + * + *

Asked rather than worked out here, for the same reason as {@link #describe}: the + * rule is two signals - an Apple model identifier like {@code iPad13,18}, or the + * {@code secureLocationsSharedSecret} only a device carries - and the exporter asks the same + * question to decide that handing over a Mac's keys is not the same act as handing over an + * AirTag's. Two copies of that would be one copy going stale. + * + *

Null means "not established", which is not the same as false. Python has not + * answered, or could not read the record, and the caller must then take the cautious road - + * a nickname changes nothing anybody else can see, and a wrong write cannot be taken back. + */ + Boolean isOwnDevice(String plistXml); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java index 5b857913..50bb0858 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java @@ -12,7 +12,6 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.concurrent.locks.ReentrantLock; import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; import io.reactivex.rxjava3.core.Observable; @@ -36,48 +35,39 @@ public static PythonAppleService getInstance() { return INSTANCE; } - /** - * Serialises all calls into Python. - *
- * FindMy.py's synchronous AppleAccount wraps an async one and drives it with a single - * asyncio event loop. RxJava schedules our fetches on a thread pool, and the periodic - * refresh in MapsActivity fires every 60 seconds regardless of whether the previous - * fetch has finished. Two fetches overlapping means two threads calling - * run_until_complete on the same loop, which fails with - * "RuntimeError: This event loop is already running" and then keeps failing. - *
- * A fetch that takes longer than the refresh interval is entirely normal for an - * accessory with no alignment yet, so this is not a rare race. - *
- * Serialising alone is not enough: the periodic refresh would still queue up behind a - * slow fetch, one entry per minute, and then fire the whole stale backlog at once when - * it finally drained. Callers on the periodic path should check {@link #isBusy()} and - * skip their turn instead - a refresh that is minutes late has no value. - */ - private static final ReentrantLock PYTHON_LOCK = new ReentrantLock(); - /** * Whether a call into Python is currently in progress. - *
- * Advisory only. A caller that acts on this can still be beaten to the lock, which is - * harmless: it just waits, exactly as it did before. + * + *

Delegates to {@link PythonLock}, which is where the lock moved when the iCloud flow + * started driving the same event loop. Kept here because the periodic refresh asks this + * service, and where the lock lives is not its business. */ public static boolean isBusy() { - return PYTHON_LOCK.isLocked(); + return PythonLock.isBusy(); } private PythonAppleService(PythonAppleAccount account) { this.account = account; } + /** + * The signed-in account, for the iCloud flow. + * + *

This one, not a second one restored from the same stored JSON. One install is + * one device to Apple (rule 11), and a parallel account would be a second HTTP session on a + * second event loop presenting the same identity. + */ + public PythonAppleAccount getAccount() { + return this.account; + } + public Observable getLastReports(final List requests, final int hoursToGoBack) { return Observable.fromCallable(() -> { if (requests.isEmpty()) { return emptyResult(); } - PYTHON_LOCK.lock(); - try { + return PythonLock.holding(() -> { var py = Python.getInstance(); var module = py.getModule(MODULE_MAIN); @@ -94,9 +84,7 @@ public Observable getLastReports(final List reque } return mapResults(returned); - } finally { - PYTHON_LOCK.unlock(); - } + }); }).subscribeOn(Schedulers.io()); } @@ -106,8 +94,7 @@ public Observable getReportsBetween(final List re return emptyResult(); } - PYTHON_LOCK.lock(); - try { + return PythonLock.holding(() -> { var py = Python.getInstance(); var module = py.getModule(MODULE_MAIN); @@ -125,14 +112,13 @@ public Observable getReportsBetween(final List re } return mapResults(returned); - } finally { - PYTHON_LOCK.unlock(); - } + }); }).subscribeOn(Schedulers.io()); } private static FetchResult emptyResult() { - return new FetchResult(Collections.emptyMap(), Collections.emptyMap()); + return new FetchResult(Collections.emptyMap(), Collections.emptyMap(), + java.util.Set.of(), java.util.Set.of()); } /** @@ -145,6 +131,8 @@ private static FetchResult emptyResult() { private static FetchResult mapResults(final PyObject locationReportsResult) { Map> results = new HashMap<>(); Map updatedAccessoryJson = new HashMap<>(); + java.util.Set exhaustedWideSearch = new java.util.HashSet<>(); + java.util.Set wideSearch = new java.util.HashSet<>(); var mapBeaconIdToResult = locationReportsResult.asMap(); for (var key : mapBeaconIdToResult.keySet()) { @@ -153,6 +141,21 @@ private static FetchResult mapResults(final PyObject locationReportsResult) { var locationReportList = perBeacon.get("reports").asList(); var updatedAccessory = perBeacon.get("updatedAccessoryJson"); + // Absent on any answer from an older bridge, which reads as "not exhausted" - the + // cautious way round, since the consequence of a wrong true is the app quietly + // giving up on somebody's tag. + final var exhausted = perBeacon.get("exhaustedWideSearch"); + if (exhausted != null && exhausted.toBoolean()) { + exhaustedWideSearch.add(key.toString()); + } + + // Absent on an older bridge, which reads as "not expensive" - so nothing is counted + // against a tag on the strength of a field that was not there. + final var wide = perBeacon.get("wideSearch"); + if (wide != null && wide.toBoolean()) { + wideSearch.add(key.toString()); + } + List reports = new LinkedList<>(); final int numReports = locationReportList.size(); for (int i = 0; i < numReports; ++i) { @@ -189,6 +192,6 @@ private static FetchResult mapResults(final PyObject locationReportsResult) { } } - return new FetchResult(results, updatedAccessoryJson); + return new FetchResult(results, updatedAccessoryJson, exhaustedWideSearch, wideSearch); } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonLock.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonLock.java new file mode 100644 index 00000000..cda507bf --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonLock.java @@ -0,0 +1,61 @@ +package dev.wander.android.opentagviewer.python; + +import java.util.concurrent.Callable; +import java.util.concurrent.locks.ReentrantLock; + +/** + * The one lock that every call into Python must be made under. + * + *

FindMy.py's synchronous {@code AppleAccount} wraps an async one and drives it with a single + * asyncio event loop. RxJava schedules our work on a thread pool, and the periodic refresh in + * {@code MapsActivity} fires every 60 seconds regardless of whether the previous fetch has + * finished. Two threads calling {@code run_until_complete} on the same loop fails with + * "RuntimeError: This event loop is already running" and then keeps failing. + * + *

A fetch that takes longer than the refresh interval is entirely normal for an accessory with + * no alignment yet, so this is not a rare race. + * + *

It lives here, rather than inside one service, because it is not one service's lock. + * The iCloud flow drives the same event loop - deliberately, since a second account would + * be a second device to Apple - so a location fetch and a keychain unlock collide exactly as two + * location fetches would. A second private lock in a second class would be no lock at all. + * + *

Serialising alone is not enough: the periodic refresh would still queue up behind a slow + * fetch, one entry per minute, and then fire the whole stale backlog at once when it finally + * drained. Callers on the periodic path should check {@link #isBusy()} and skip their turn + * instead - a refresh that is minutes late has no value. + */ +public final class PythonLock { + private static final ReentrantLock LOCK = new ReentrantLock(); + + private PythonLock() { + } + + /** + * Run one call into Python, with nothing else in there at the same time. + * + *

Scope it to the call and nothing more. The reason this is a method taking the + * work, rather than a lock to take and release, is the iCloud flow: it is several calls with + * a person answering a dialog between them, and holding this across one of those waits would + * stop every location refresh in the app until they got round to typing. Each step takes it, + * finishes, and gives it back; the waiting happens in Java with nothing held. + */ + public static T holding(final Callable work) throws Exception { + LOCK.lock(); + try { + return work.call(); + } finally { + LOCK.unlock(); + } + } + + /** + * Whether a call into Python is currently in progress. + * + *

Advisory only. A caller that acts on this can still be beaten to the lock, which is + * harmless: it just waits, exactly as it did before. + */ + public static boolean isBusy() { + return LOCK.isLocked(); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/AccessoryRecords.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/AccessoryRecords.java new file mode 100644 index 00000000..a57f46ad --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/AccessoryRecords.java @@ -0,0 +1,33 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * One accessory as the three plists a bundle carries for it. + * + *

The same documents the zip importer already reads, which is the point of the whole + * exercise: an accessory read from an account and one read from a bundle become the same rows in + * the same tables, with no second format and no zip in the middle. + */ +@Getter +@AllArgsConstructor +public class AccessoryRecords { + private final String beaconId; + + /** The {@code OwnedBeacons} record. Carries the key material. */ + private final String ownedBeaconPlist; + + /** + * Its {@code BeaconNamingRecord}, or null where CloudKit holds none. + * + *

Genuinely optional here, unlike in a bundle. A zip's importer inner-joins the two, so an + * accessory exported without one goes silently missing - but nothing is being written to a + * zip, the app left-joins, and a tag nothing ever named is a thing it already knows how to + * show. Inventing a name would put a tag in the user's list as though they had named it. + */ + private final String namingRecordPlist; + + /** Its {@code KeyAlignmentRecord}, if it has one. Absence is normal. */ + private final String keyAlignmentPlist; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/AccessoryRenamer.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/AccessoryRenamer.java new file mode 100644 index 00000000..5fe95128 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/AccessoryRenamer.java @@ -0,0 +1,72 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import androidx.annotation.NonNull; + +import java.util.Optional; + +import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; +import dev.wander.android.opentagviewer.python.AppDependencies; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * One rename, from a screen that holds no iCloud session of its own. + * + *

The tag page is reached from the device list and knows nothing about Apple. A rename that + * has to reach the account therefore opens a session, uses it once and closes it - which is + * affordable precisely because renaming is rare, and is the reason this is not a session the + * screen keeps alive for as long as somebody is looking at a tag. + * + *

It resumes as the member the app already is, and will not ask for a passcode. Being a + * member is what the join bought; if the stored membership has gone or stopped working there is + * nothing to fall back on here, because the fallback is a device passcode and a whole flow with + * screens in it. The rename fails, the caller says so, and the user's next visit to the account + * screen puts it right. + */ +public final class AccessoryRenamer { + + private final KeychainMembershipRepository memberships; + + public AccessoryRenamer(@NonNull final KeychainMembershipRepository memberships) { + this.memberships = memberships; + } + + /** + * Change the accessory's name and emoji in the user's Apple account. + * + *

Fails with {@link ICloudFailure#MEMBERSHIP_UNUSABLE} when this app is not a member, which + * is the honest answer: nothing is wrong with the tag or the name, the app simply cannot + * write to that account right now. + * + * @param name the new name, or empty to leave it alone. + * @param emoji the new emoji, or empty to leave it alone. + */ + public Completable rename(@NonNull final String beaconId, @NonNull final String plistXml, + final String name, final String emoji) { + return this.memberships.get() + .firstOrError() + .flatMapCompletable(held -> writeWith(held, beaconId, plistXml, name, emoji)) + .subscribeOn(Schedulers.io()); + } + + private static Completable writeWith(final Optional held, + final String beaconId, final String plistXml, + final String name, final String emoji) { + if (held.isEmpty()) { + return Completable.error(new ICloudException( + ICloudFailure.MEMBERSHIP_UNUSABLE, + "This app is not a member of the account's keychain, so it cannot write" + + " to it.")); + } + + final ICloudService icloud = AppDependencies.icloud(); + + // `close` in doFinally rather than in the happy path: two of these steps hold sockets, + // and a rename that fails half way through would otherwise leak them for the life of + // the process. It never throws. + return icloud.open() + .andThen(icloud.resume(held.get().getPeerJson())) + .andThen(icloud.rename(beaconId, plistXml, name, emoji)) + .doFinally(icloud::close); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/AccountRefresher.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/AccountRefresher.java new file mode 100644 index 00000000..828c7f49 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/AccountRefresher.java @@ -0,0 +1,115 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import dev.wander.android.opentagviewer.db.repo.BeaconRepository; +import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; +import dev.wander.android.opentagviewer.python.AppDependencies; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * Re-read the Apple account on the app's own initiative, with nobody watching. + * + *

This is what being a member of the keychain is for. Joining buys the ability to read + * without a device passcode, and until now the only thing that spent it was somebody opening the + * account screen and asking. So a tag added in Find My, or renamed there, or removed, did not + * reach the app until the user went looking for a button - which is the opposite of how a linked + * account should behave. + * + *

Silent both ways. Nothing here has a screen: it succeeds by the device list quietly + * being right the next time it is opened, and it fails by logging. The one exception is a + * membership the account no longer honours - that is forgotten, because leaving it stored means + * retrying dead keys on every interval forever, and forgetting it puts the app back in the state + * where the UI offers to link again. + * + *

It does not decide when to run. That is {@link AccountReadPolicy}, kept apart so the + * timing can be tested without an Apple account and this can be tested without a clock. + */ +public final class AccountRefresher { + private static final String TAG = AccountRefresher.class.getSimpleName(); + + private final KeychainMembershipRepository memberships; + private final BeaconRepository beacons; + + public AccountRefresher(@NonNull final KeychainMembershipRepository memberships, + @NonNull final BeaconRepository beacons) { + this.memberships = memberships; + this.beacons = beacons; + } + + /** + * Read the account and bring the stored tags into line with it. + * + * @return the ids now held for the account, or an empty list when there was nothing to do - + * which is the ordinary answer for somebody who has never linked one. + */ + public Observable> refresh() { + return this.memberships.get() + .firstOrError() + .flatMapObservable(this::readWith) + .subscribeOn(Schedulers.io()); + } + + private Observable> readWith(final Optional held) { + if (held.isEmpty()) { + // Not linked. Not a failure, and not worth a log line every interval. + return Observable.just(List.of()); + } + + final ICloudService icloud = AppDependencies.icloud(); + if (icloud == null) { + Log.i(TAG, "No usable Apple session, so the account cannot be re-read yet"); + return Observable.just(List.of()); + } + + return icloud.open() + .andThen(icloud.resume(held.get().getPeerJson())) + .andThen(icloud.fetch()) + .flatMap(fetched -> icloud.records(idsOf(fetched))) + .flatMap(this.beacons::refreshAccountBeacons) + .doOnNext(ids -> Log.i(TAG, "Re-read the Apple account: " + ids.size() + " tags")) + .onErrorResumeNext(error -> this.recoverFrom(error)) + // In doFinally rather than after the last step: two of these hold sockets, and a + // read that failed half way would otherwise leak them for the life of the + // process. It never throws. + .doFinally(icloud::close); + } + + /** + * A failed read is not reported to anybody, but a dead membership is acted on. + * + *

Keeping one that no longer works means retrying keys that cannot succeed on every + * interval, forever, and never telling the user why their tags stopped changing. Forgetting + * it costs a device passcode once and puts the app back where the screens can explain + * themselves. + */ + private Observable> recoverFrom(final Throwable error) { + final boolean membershipIsDead = error instanceof ICloudException + && ((ICloudException) error).getFailure() == ICloudFailure.MEMBERSHIP_UNUSABLE; + + if (!membershipIsDead) { + Log.w(TAG, "Could not re-read the Apple account; leaving the stored tags alone", error); + return Observable.just(List.of()); + } + + Log.w(TAG, "The stored keychain membership no longer works, so it is being forgotten." + + " The app will offer to link the account again.", error); + + return this.memberships.forget().andThen(Observable.just(List.of())); + } + + private static List idsOf(final ICloudFetch fetched) { + final List wanted = new ArrayList<>(); + for (final ICloudAccessory accessory : fetched.getAccessories()) { + wanted.add(accessory.getBeaconId()); + } + return wanted; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/EscrowPasscode.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/EscrowPasscode.java new file mode 100644 index 00000000..7a616d70 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/EscrowPasscode.java @@ -0,0 +1,82 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import android.util.Base64; + +import java.security.SecureRandom; + +/** + * The passcode this app's own escrow record is enrolled under. + * + *

Not a passcode anybody types. It protects the record created when this device is + * added to the user's account, and it is generated, stored and used entirely by the app. Every + * reason a screen-lock passcode is short - somebody has to remember it, somebody has to key it in + * on a phone - simply does not apply, so the only sensible size is one nobody would attempt. + * + *

Deliberately not Crockford base32, which is what + * {@link dev.wander.android.opentagviewer.util.parse.BundlePasscode} uses. That alphabet drops + * I, L, O and U so a person reading a code off one screen does not mistype it into another - a + * real constraint there, and an irrelevant one here that costs entropy per character to honour. + * Sharing it would also tie this to a decision made for legibility: narrowing that alphabet + * further, for better reasons, would quietly weaken this. + * + *

So the strength is stated in bytes of randomness and the encoding is incidental - + * base64url only because it survives every layer between here and Apple without escaping, and + * because 64 divides 256 evenly, so no character is likelier than another. + * + *

Deliberately not numeric, either. Enrolment publishes + * {@code SecureBackupUsesNumericPassphrase} in the record's metadata, and a numeric passphrase + * announces itself as the kind of thing a six-digit PIN protects. The honest disclosure here is + * "not a PIN". + */ +public final class EscrowPasscode { + + private EscrowPasscode() { + } + + /** + * Bytes of randomness behind each passcode: 256 bits. + * + *

There is no upper bound in the protocol. Enrolment rejects only an empty passcode, + * on the grounds that a record enrolled under one "could be recovered by anyone" - everything + * above that is the caller's choice, and nothing about this one is rationed. + */ + public static final int ENTROPY_BYTES = 32; + + /** + * A fresh passcode, from the platform's cryptographically secure source. + * + *

{@link SecureRandom} rather than {@code Random}: this is the only secret protecting a + * record that can yield the keys to the user's Find My data, and a predictable secret is + * indistinguishable from a strong one by looking at it. + */ + public static String generate() { + final byte[] entropy = new byte[ENTROPY_BYTES]; + new SecureRandom().nextBytes(entropy); + + // NO_WRAP and NO_PADDING so the value is one unbroken token: a newline or an `=` in + // something that travels through a plist and an SRP exchange is an avoidable variable. + return Base64.encodeToString( + entropy, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); + } + + /** + * Whether a stored value is one of ours and still intact. + * + *

Worth checking rather than assuming: this is read back from storage before being used to + * recover, and a truncated or empty one fails in a way indistinguishable from Apple refusing + * the exchange - which would send somebody hunting the wrong problem entirely. + */ + public static boolean isWellFormed(final String passcode) { + if (passcode == null || passcode.isEmpty()) { + return false; + } + + try { + final byte[] decoded = Base64.decode( + passcode, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); + return decoded.length == ENTROPY_BYTES; + } catch (IllegalArgumentException e) { + return false; + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudAccessory.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudAccessory.java new file mode 100644 index 00000000..2c4a63c5 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudAccessory.java @@ -0,0 +1,44 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * One accessory found in the account, described well enough to choose from. + * + *

No key material. A picker needs names; the records themselves come separately, for + * the ones the user actually picks - see {@link ICloudService#records}. + */ +@Getter +@AllArgsConstructor +public class ICloudAccessory { + private final String beaconId; + + /** What the owner called it, or null if nothing ever named it. */ + private final String name; + + private final String emoji; + + /** How to show it in a list, which for a nameless one is still not much. */ + private final String label; + + /** + * What kind of thing it is, its serial, and when it was paired. + * + *

This is what an accessory with no name has instead of one. "unnamed" three times over + * is not a list anybody can choose from, and a pairing date is often the thing a person + * recognises, because they remember buying it. + */ + private final String details; + + /** + * Whether a key alignment record came with it. + * + *

Without one, the first locate searches the tag's whole key history - tens of thousands + * of keys for an older tag, which is slow enough to look like abuse of the account. + */ + private final boolean hasAlignment; + + /** False when nothing ever named it. Not a problem to solve before importing. */ + private final boolean hasName; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudException.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudException.java new file mode 100644 index 00000000..0e6abdde --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudException.java @@ -0,0 +1,28 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import lombok.Getter; + +/** + * A step of the iCloud flow reporting why it did not work. + * + *

An exception rather than a result type because these travel back through RxJava, where a + * failure belongs in {@code onError} - a screen that has to unwrap a success value to find out + * it failed is a screen that will forget to. + * + *

Both halves are carried on purpose. {@link #getFailure()} is what the screen branches on, + * so its wording stays in {@code strings.xml}. {@link #getDetail()} is what Python said, for the + * log and for {@link ICloudFailure#UNKNOWN}, where there is nothing better to show - and it is + * never empty, which is the whole reason the bridge returns failures as values instead of + * letting exceptions cross the boundary with {@code str(e)} of nothing. + */ +@Getter +public class ICloudException extends RuntimeException { + private final ICloudFailure failure; + private final String detail; + + public ICloudException(final ICloudFailure failure, final String detail) { + super(failure + ": " + detail); + this.failure = failure; + this.detail = detail; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailure.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailure.java new file mode 100644 index 00000000..32396e77 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailure.java @@ -0,0 +1,112 @@ +package dev.wander.android.opentagviewer.python.icloud; + +/** + * Why a step of the iCloud flow did not work, in the terms the screen has to distinguish. + * + *

A closed set, not the exception text. The bridge reports a {@code reason} string + * precisely so the wording lives in {@code strings.xml} and can be translated - branching on a + * message would put English in the code and break the moment the message was reworded. The + * detail is carried alongside for the log and for the cases where there is genuinely nothing + * better to show. + */ +public enum ICloudFailure { + /** + * The account is not in a state that can talk to iCloud, or the client is not open. + * + *

Recovered from the same way an expired session is: send the user to sign in again. + */ + NOT_SIGNED_IN, + + /** + * This account has nothing that can unlock its keychain, and never will. + * + *

The expected answer for a real class of user rather than an error: an Apple ID that has + * never had an iPhone, iPad or Mac on it has never escrowed a keychain. It is also, in + * practice, the same person as "this account owns no tags" - only an iPhone or iPad can + * register one - so the flow stops here rather than at the fetch, before asking for a + * passcode they do not have. + * + *

The answer for them is the import path. Telling them to try again later is a lie that + * costs them an evening. + */ + NOTHING_TO_RECOVER_FROM, + + /** + * Nothing was reported usable at all, which reads as a service having a bad day. + * + *

Must not be shown as {@link #NOTHING_TO_RECOVER_FROM}. The advice is the opposite + * one: this is worth trying again later and that never will be. Collapsing the two tells + * somebody with a perfectly good account that they permanently own no tags, and sends them + * off to find a friend with a Mac. + */ + SERVICE_UNSURE, + + /** + * The escrow service did not accept the passcode. + * + *

Not proof it was wrong. FindMy.py's own first advice is to try the same passcode + * again, because the exchange has been seen to fail intermittently and then succeed. Copy + * that says "incorrect passcode" is stating something this app does not know. + */ + PASSCODE_REJECTED, + + /** A device was chosen that this session never listed. A bug in the screen, not the user. */ + NO_SUCH_RECORD, + + /** A join was asked for before anything unlocked, so no peer could sponsor it. A bug here. */ + NOT_UNLOCKED, + + /** + * The stored membership no longer reads the keychain. + * + *

Not a broken app, and not a retry. The peer may simply have been removed from the + * account - which is how somebody revokes this app - so the way forward is to ask for a + * device passcode and join again, not to try the same stored keys a second time. + */ + MEMBERSHIP_UNUSABLE, + + /** An accessory was asked for that this session never fetched. Also a bug in the screen. */ + NO_SUCH_ACCESSORY, + + /** + * A rename was asked for on one of the owner's own devices. + * + *

An iPhone, iPad or Mac takes its name from more places than the naming record, so + * writing that one would leave Find My disagreeing with the device itself. The app nicknames + * those locally instead and goes on showing the real name beside the nickname. + * + *

Reaching here means the screen offered a write it should not have - the two sides + * decide from the same {@code is_own_device}, so it is a disagreement worth logging rather + * than a state a user can create. + */ + NOT_AN_ACCESSORY, + + /** Anything else. The detail carries what there is to say. */ + UNKNOWN; + + /** + * Map the bridge's wire value, defaulting to {@link #UNKNOWN} rather than throwing. + * + *

A reason added in Python and not yet known here must degrade to "something went wrong, + * here is what it said" - which is a poor screen but a working one. Throwing would turn a + * new failure mode into a crash on the screen reporting failures. + */ + public static ICloudFailure fromWire(final String reason) { + if (reason == null) { + return UNKNOWN; + } + + switch (reason) { + case "not_signed_in": return NOT_SIGNED_IN; + case "nothing_to_recover_from": return NOTHING_TO_RECOVER_FROM; + case "service_unsure": return SERVICE_UNSURE; + case "passcode_rejected": return PASSCODE_REJECTED; + case "no_such_record": return NO_SUCH_RECORD; + case "not_unlocked": return NOT_UNLOCKED; + case "membership_unusable": return MEMBERSHIP_UNUSABLE; + case "no_such_accessory": return NO_SUCH_ACCESSORY; + case "not_an_accessory": return NOT_AN_ACCESSORY; + default: return UNKNOWN; + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFetch.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFetch.java new file mode 100644 index 00000000..e7badbad --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFetch.java @@ -0,0 +1,41 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * What one account holds: what can be imported, and what was set aside. + * + *

Both halves reach the screen. "Fewer tags than expected" and "some of those were never tags" + * look identical from outside, and the second is the common one - an account's own iPhones and + * Macs come back in the same records and are dropped for having no private key. + */ +@Getter +@AllArgsConstructor +public class ICloudFetch { + private final List accessories; + private final List skipped; + + /** + * An account that owns no tags at all. + * + *

Worth its own screen. In practice the flow usually stops earlier, at + * {@link ICloudFailure#NOTHING_TO_RECOVER_FROM}, because an account with no Apple device on + * it has no escrow record either - but an account that has a Mac and no tags reaches here + * instead, and lands on the same advice: import a bundle from somebody who owns them. + */ + public boolean isEmpty() { + return this.accessories.isEmpty(); + } + + @Getter + @AllArgsConstructor + public static class SkippedAccessory { + private final String beaconId; + + /** What it appears to be, with the evidence rather than a verdict. */ + private final String reason; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudService.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudService.java new file mode 100644 index 00000000..9a006783 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudService.java @@ -0,0 +1,141 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import java.util.List; + +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Observable; + +/** + * Reading the tags in the user's own Apple account. + * + *

Four steps, in order, because a person answers something between them: + * + *

+ *   open() -> recoveryOptions() -> unlock(serial, passcode) -> fetch() -> records(ids)
+ * 
+ * + *

An interface so the screens can be tested without an Apple account. Every failure + * worth showing a user - an account with nothing to recover from, a service having a bad day, a + * rejected passcode - is unreachable from a test that needs real credentials, which means those + * are exactly the paths that would never be covered. {@code FakeICloudService} drives them all. + * + *

Failures arrive as {@link ICloudException} in {@code onError}, carrying an + * {@link ICloudFailure} to branch on so the wording stays in {@code strings.xml}. + * + *

Every call takes the shared Python lock for its own duration and gives it back, so the + * app's periodic location refresh is delayed by a step but never by the user's thinking time. + * Implementations must not hold it across a dialog. + */ +public interface ICloudService { + + /** + * Open the Find My client: a keychain session and a CloudKit client. + * + *

Nothing is decrypted yet - that needs keys, and keys need {@link #unlock}. + */ + Completable open(); + + /** + * What this account could unlock its keychain from. + * + *

Never empty on success: the two ways of being empty are + * {@link ICloudFailure#NOTHING_TO_RECOVER_FROM} and {@link ICloudFailure#SERVICE_UNSURE}, + * and they are errors precisely so a screen cannot accidentally treat them alike. + */ + Observable> recoveryOptions(); + + /** + * Recover the keychain keys with one device's screen-lock passcode. + * + *

One attempt per call. The retry lives with the dialog that spends it, and so does + * the cap - see {@link #MAX_UNLOCK_ATTEMPTS}, which has to be respected: attempts are + * probably a limited resource on Apple's end, and what this service allows is not + * established. + */ + Completable unlock(String serial, String passcode); + + /** + * How many times a passcode may be offered before the flow gives up. + * + *

A bound rather than a free retry, mirroring {@code exporter.icloud.MAX_UNLOCK_ATTEMPTS}. + * FindMy.py says Apple's escrow services generally cap attempts and that what this one allows + * is not established - which is a good reason not to find out on somebody's real account. + */ + int MAX_UNLOCK_ATTEMPTS = 3; + + /** + * Become a member of the account's keychain in this app's own right. This one writes. + * + *

Everything else here reads. This enrols an escrow record and adds a peer, and it is + * worth that for one reason: a non-member reads with view keys it holds a share of, and those + * keep working right up until the keys roll - expected whenever the circle's + * membership changes. Only a current member is given shares of the new ones, so a non-member + * goes quietly stale, still holding keys and decrypting nothing new. Here that is a map that + * stopped updating for no reason, which is the failure nobody can diagnose for themselves. + * + *

Never call this twice for one intent. A response that will not decode is not a + * call that failed, and a timeout does not establish that nothing was sent. Treat any failure + * as "it may have happened" and recover by looking at the account, not by trying again. + * + *

Must follow a successful {@link #unlock} in the same session - the peer that unlock + * recovered is what sponsors the join. + * + * @param escrowPasscode the passcode this app's own record will be recoverable under, + * from {@link EscrowPasscode}. Not the user's, and never shown. + * @return the membership to store before anything else can go wrong: its keys are the + * only copy in existence. + */ + Observable join(String escrowPasscode); + + /** + * Read the keychain as the member this app already is - no passcode, nothing borrowed. + * + *

What the join bought, and the call that replaces asking on every refresh. + * + *

Fails with {@link ICloudFailure#MEMBERSHIP_UNUSABLE} when the stored keys no longer + * work, which is not a retry: the peer may have been removed from the account, and the way + * forward is a passcode and a fresh join. + */ + Completable resume(String peerJson); + + /** Read and decrypt the account's accessories, described but without their key material. */ + Observable fetch(); + + /** The chosen accessories, as the plists the importer already reads. */ + Observable> records(List beaconIds); + + /** + * Change an accessory's name and emoji in iCloud. The other call that writes. + * + *

Unlike {@link #join}, this changes something the owner sees: the name and emoji Find My + * shows for the accessory, on their own devices. For an AirTag or a Find My-certified tag the + * naming record is the only place those live, so writing it is the rename. + * + *

Refused for one of the owner's own devices, with + * {@link ICloudFailure#NOT_AN_ACCESSORY}. An iPhone, iPad or Mac takes its name from more + * places than this record, so writing one would leave Find My disagreeing with the device + * itself - those get a local nickname instead. Python decides, from the stored plist, using + * the same {@code is_own_device} the exporter uses; the app must not re-derive that rule. + * + *

Needs keychain keys, so it follows {@link #resume} or {@link #unlock} in the same + * session. Without them it fails with {@link ICloudFailure#NOT_UNLOCKED}, which is a session + * to reopen rather than an error to show. + * + * @param beaconId the accessory's identifier - what {@code associatedBeacon} names. + * @param plistXml its stored {@code OwnedBeacons} plist, which is how Python tells an + * accessory from a device without reading the whole account. + * @param name the new name, or empty to leave it as it is. + * @param emoji the new emoji, or empty to leave it as it is. Empty means "leave + * alone", not "clear it" - sending both every time would blank the emoji + * of every accessory anybody renamed. + */ + Completable rename(String beaconId, String plistXml, String name, String emoji); + + /** + * Close the client. + * + *

Call it from a {@code finally}: two of the steps hold sockets, and an abandoned session + * leaks them for the life of the process. Never throws. + */ + void close(); +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/KeychainMembership.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/KeychainMembership.java new file mode 100644 index 00000000..08cc8bc1 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/KeychainMembership.java @@ -0,0 +1,58 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * What a completed join produced, and what has to outlive the process to use it. + * + *

The keys inside {@link #getPeerJson()} are the only copy in existence. Lose them and + * this app's peer is stranded on the user's account - unusable, and not removable from here + * either. So this is stored before anything else is allowed to go wrong, and it is stored + * encrypted: FindMy.py serialises a circle member's private keys in the clear, and says so. + * + *

Two independent ways back, and neither substitutes for the other: + * + *

    + *
  • {@link #getPeerJson()} is how an ordinary refresh reads the keychain with no passcode. + * This is the point of having joined.
  • + *
  • {@link #getEntropy()} with {@link #getEscrowPasscode()} recovers the same peer + * through escrow, which is the way back if this app's encrypted store is ever + * destroyed but the account still holds the record.
  • + *
+ */ +@Getter +@AllArgsConstructor +public class KeychainMembership { + + /** + * The membership itself: a peer id and two private keys, as FindMy.py serialises them. + * + *

Opaque here on purpose. This app has no business parsing a circle member's keys; it + * stores what it was given and hands the same bytes back. + */ + private final String peerJson; + + /** The bottle's entropy, base64. The escrow route's half of the recovery. */ + private final String entropy; + + /** + * The passcode this app's own escrow record was enrolled under. + * + *

Generated by {@link EscrowPasscode}, 256 bits, and never shown to anybody - the user has + * no use for it and could not act on it. Kept solely so the escrow route above is available. + */ + private final String escrowPasscode; + + /** The record's label, which is how §7.1's deletion addresses it. */ + private final String label; + + /** + * How many view keys were re-addressed to this peer when it joined. + * + *

Worth keeping for a log rather than for logic: a join reporting zero is a join that + * technically succeeded and bought nothing, and that is the sort of thing only visible in + * hindsight when somebody reports that fetching stopped working. + */ + private final int shares; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/PythonICloudService.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/PythonICloudService.java new file mode 100644 index 00000000..243e726d --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/PythonICloudService.java @@ -0,0 +1,242 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import android.util.Log; + +import com.chaquo.python.PyObject; +import com.chaquo.python.Python; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +import dev.wander.android.opentagviewer.python.PythonAppleAccount; +import dev.wander.android.opentagviewer.python.PythonLock; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * {@link ICloudService} over {@code icloud_bridge}, the Python module that drives + * {@code exporter.icloud}. + * + *

Thin on purpose. The flow, the retry policy and every decision about what a failure means + * live on the Python side, where the library is; this translates JSON into types and reasons into + * an enum, and does nothing else. A second implementation of the flow in Java would be a second + * thing to get wrong. + */ +public class PythonICloudService implements ICloudService { + private static final String TAG = PythonICloudService.class.getSimpleName(); + + private static final String MODULE = "icloud_bridge"; + + private final PyObject session; + + private PythonICloudService(final PyObject session) { + this.session = session; + } + + /** + * Start a session on the account the app is already signed in with. + * + *

Returns null when FindMy.py's account internals have moved and the flow cannot be driven + * - which the caller should treat exactly as an expired session, by sending the user to sign + * in again. A null here is not a crash and must not become one. + */ + public static PythonICloudService openFor(final PythonAppleAccount account) { + try { + final PyObject made = Python.getInstance().getModule(MODULE) + .callAttr("openSession", account.getAccountObj()); + + // **Null is the whole check.** Chaquopy hands a Python `None` back as a Java null, + // so this is already the answer - and the belt-and-braces version of it, + // `made.toJava(Object.class)`, is not redundant but fatal: Chaquopy cannot convert an + // arbitrary Python object to java.lang.Object and throws ClassCastException, which + // happens on the path where a session was successfully created. That killed the real + // iCloud flow on every device while every test went on passing, because the tests + // replace this class with a fake and nothing else calls it. + if (made == null) { + Log.e(TAG, "icloud_bridge.openSession returned nothing; the iCloud flow is" + + " unavailable on this account"); + return null; + } + + return new PythonICloudService(made); + } catch (Exception e) { + Log.e(TAG, "Could not start an iCloud session", e); + return null; + } + } + + @Override + public Completable open() { + return Completable.fromAction(() -> answered("open")) + .subscribeOn(Schedulers.io()); + } + + @Override + public Observable> recoveryOptions() { + return Observable.fromCallable(() -> { + final JSONArray devices = answered("recoveryOptions").getJSONArray("devices"); + + final List found = new ArrayList<>(); + for (int i = 0; i < devices.length(); i++) { + final JSONObject device = devices.getJSONObject(i); + found.add(new RecoverableDevice( + device.getString("serial"), + device.getString("description"), + device.optString("name", ""), + device.optString("model", ""), + device.optString("modelClass", ""), + // Absent rather than zero would be a date in 1970 on the tile. + device.isNull("escrowedAtMs") ? 0L : device.optLong("escrowedAtMs", 0L))); + } + + return found; + }).subscribeOn(Schedulers.io()); + } + + @Override + public Completable unlock(final String serial, final String passcode) { + return Completable.fromAction(() -> answered("unlock", serial, passcode)) + .subscribeOn(Schedulers.io()); + } + + @Override + public Observable join(final String escrowPasscode) { + return Observable.fromCallable(() -> { + final JSONObject answer = answered("join", escrowPasscode); + + return new KeychainMembership( + // Stored as the bytes Python produced. This app has no business parsing a + // circle member's keys; it keeps what it was given and hands the same back. + answer.getJSONObject("peer").toString(), + answer.getString("entropy"), + escrowPasscode, + answer.optString("label", ""), + answer.optInt("shares", 0)); + }).subscribeOn(Schedulers.io()); + } + + @Override + public Completable resume(final String peerJson) { + return Completable.fromAction(() -> answered("resume", peerJson)) + .subscribeOn(Schedulers.io()); + } + + @Override + public Completable rename(final String beaconId, final String plistXml, + final String name, final String emoji) { + return Completable.fromAction( + () -> answered("rename", beaconId, plistXml, name, emoji)) + .subscribeOn(Schedulers.io()); + } + + @Override + public Observable fetch() { + return Observable.fromCallable(() -> { + final JSONObject answer = answered("fetch"); + + final JSONArray found = answer.getJSONArray("accessories"); + final List accessories = new ArrayList<>(); + for (int i = 0; i < found.length(); i++) { + final JSONObject one = found.getJSONObject(i); + accessories.add(new ICloudAccessory( + one.getString("beaconId"), + // Null rather than the string "null", which is what optString gives for + // a JSON null and would reach the screen as a tag called null. + one.isNull("name") ? null : one.getString("name"), + one.isNull("emoji") ? null : one.getString("emoji"), + one.getString("label"), + one.getString("details"), + one.getBoolean("hasAlignment"), + one.getBoolean("hasName"))); + } + + final JSONArray setAside = answer.getJSONArray("skipped"); + final List skipped = new ArrayList<>(); + for (int i = 0; i < setAside.length(); i++) { + final JSONObject one = setAside.getJSONObject(i); + skipped.add(new ICloudFetch.SkippedAccessory( + one.getString("beaconId"), one.getString("reason"))); + } + + return new ICloudFetch(accessories, skipped); + }).subscribeOn(Schedulers.io()); + } + + @Override + public Observable> records(final List beaconIds) { + return Observable.fromCallable(() -> { + final JSONArray selection = new JSONArray(); + for (final String beaconId : beaconIds) { + selection.put(new JSONObject().put("beaconId", beaconId)); + } + + final JSONArray taken = + answered("records", selection.toString()).getJSONArray("accessories"); + + final List records = new ArrayList<>(); + for (int i = 0; i < taken.length(); i++) { + final JSONObject one = taken.getJSONObject(i); + records.add(new AccessoryRecords( + one.getString("beaconId"), + one.getString("ownedBeaconPlist"), + // Null where nothing ever named it, which the importer handles. + one.isNull("namingRecordPlist") ? null : one.getString("namingRecordPlist"), + one.isNull("keyAlignmentPlist") ? null : one.getString("keyAlignmentPlist"))); + } + + return records; + }).subscribeOn(Schedulers.io()); + } + + @Override + public void close() { + try { + PythonLock.holding(() -> { + this.session.callAttr("close"); + return null; + }); + } catch (Exception e) { + // Callers close from a `finally`. Throwing here would replace whatever real failure + // sent them there with a confusing one about closing. + Log.w(TAG, "Closing the iCloud session failed", e); + } + } + + /** + * Make one call into the bridge and return its answer, or throw what it reported. + * + *

Two things happen here and both matter. + * + *

The Python lock is taken for the call and given straight back. The iCloud flow and + * the app's periodic location refresh drive the same asyncio event loop, and two threads in + * {@code run_until_complete} on one loop fails permanently. Scoping it to the call rather + * than the flow is what keeps a passcode dialog from freezing every location update in the + * app until the user types. + * + *

A reported failure becomes an exception; a raised one is not expected. The bridge + * returns failures as values precisely so their message is never empty, so anything actually + * thrown here is a bug rather than a user-facing condition - and is reported as + * {@link ICloudFailure#UNKNOWN} with whatever text it had. + */ + private JSONObject answered(final String step, final Object... arguments) throws Exception { + final String returned = PythonLock.holding( + () -> this.session.callAttr(step, arguments).toString()); + + final JSONObject answer = new JSONObject(returned); + + if (!answer.optBoolean("ok", false)) { + final ICloudFailure failure = ICloudFailure.fromWire(answer.optString("reason", null)); + final String detail = answer.optString("message", ""); + + Log.w(TAG, "icloud_bridge." + step + " reported " + failure + ": " + detail); + + throw new ICloudException(failure, detail); + } + + return answer; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/RecoverableDevice.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/RecoverableDevice.java new file mode 100644 index 00000000..0265d0ff --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/RecoverableDevice.java @@ -0,0 +1,77 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * One of the user's Apple devices whose passcode could unlock the keychain. + * + *

The serial is how the unlock step names it back to Python - the escrow record itself stays + * on the Python side, because it is a live object holding key material and not a thing to + * reconstruct from a string. + * + *

The rest is here so the screen can lay out a tile rather than print a sentence. + */ +@Getter +@AllArgsConstructor +public class RecoverableDevice { + private final String serial; + + /** + * FindMy.py's own one-line description. + * + *

Kept as the fallback for anything the tile cannot express, and because it is the + * library's opinion about how a record should read. + */ + private final String description; + + /** + * What the user called this device, which is often empty. + * + *

Somebody who never renamed their phone has no name on the record. Use + * {@link #displayName()} rather than this. + */ + private final String name; + + /** The hardware model, e.g. {@code iPhone15,2}. Not something to show a person unaided. */ + private final String model; + + /** {@code iPhone}, {@code iPad}, {@code Mac}… - what the icon is chosen from. */ + private final String modelClass; + + /** + * When the escrow record was created, in Unix milliseconds, or 0 if unknown. + * + *

Not when the device was last used, and it must never be labelled that way. A + * record escrowed three months ago says nothing about whether that phone was used this + * morning, and telling somebody their daily iPhone was "last used 3 months ago" would send + * them to the wrong device. + */ + private final long escrowedAtMs; + + /** + * What to put on the tile. + * + *

The user's own name where there is one; otherwise the class - "iPhone" is a real word + * and tells them something, where FindMy.py's "unnamed device" fallback tells them nothing. + * The model only as a last resort, because {@code iPhone15,2} is not a name. + */ + public String displayName() { + if (this.name != null && !this.name.isBlank()) { + return this.name; + } + if (this.modelClass != null && !this.modelClass.isBlank()) { + return this.modelClass; + } + if (this.model != null && !this.model.isBlank()) { + return this.model; + } + + return this.description; + } + + /** Whether the name shown is the user's own, so a tile can add the model without repeating. */ + public boolean hasUserGivenName() { + return this.name != null && !this.name.isBlank(); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/BeaconIcon.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/BeaconIcon.java index bc652591..b11015d8 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/BeaconIcon.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/BeaconIcon.java @@ -1,6 +1,13 @@ package dev.wander.android.opentagviewer.ui; +import android.content.res.ColorStateList; +import android.util.TypedValue; +import android.widget.ImageView; + import androidx.annotation.DrawableRes; +import androidx.appcompat.content.res.AppCompatResources; + +import com.google.android.material.button.MaterialButton; import dev.wander.android.opentagviewer.R; import dev.wander.android.opentagviewer.data.model.BeaconInformation; @@ -48,9 +55,103 @@ public static int forBeacon(final BeaconInformation beacon) { if (beacon.isCustomAccessory()) { return R.drawable.tag_self_generated; } + + final int ownDevice = forAnApplemodel(beacon.getModel()); + if (ownDevice != 0) { + return ownDevice; + } + if (beacon.getVendorId() == APPLE_VENDOR_ID) { return R.drawable.apple; } - return R.drawable.tag_third_party; + return R.drawable.findmy_accessory; + } + + /** + * The picture for one of the owner's own devices, or 0 if this is not one. + * + *

Checked before the vendor id, because the vendor id does not identify these. An + * iPad's record carries {@code vendorId} -1 and an AirTag's carries 76, so a version of this + * that asked about the vendor first sent every iPad, iPhone and Mac on the account down the + * third-party branch and drew them as generic Find My accessories - which is what an account + * read produces most of, and looked like several identical unknown tags in a list. + * + *

The model is the direct evidence and is only ever there for these. An accessory + * leaves {@code model} empty and says what it is through its product and vendor ids; a device + * fills it with an Apple model identifier - {@code iPad13,18}, {@code MacBookAir10,1}. That + * makes this a lookup rather than a heuristic, which is why it can stay here rather than + * crossing to {@code hardware.is_own_device}: that answers whether exporting one is a + * different act, this only chooses a picture, and an icon that arrived asynchronously would + * visibly change under the user. + */ + @DrawableRes + private static int forAnApplemodel(final String model) { + if (model == null || model.isEmpty()) { + return 0; + } + + if (model.startsWith("iPad")) { + return R.drawable.tablet_24px; + } + if (model.startsWith("iPhone") || model.startsWith("iPod")) { + return R.drawable.smartphone_24px; + } + if (model.startsWith("Mac") || model.startsWith("iMac")) { + return R.drawable.laptop_24px; + } + + // A Watch, a Vision Pro, something not shipped yet: Apple hardware whose shape this has + // no drawing of. Apple's own logo says "one of your Apple devices" and is not a guess. + return R.drawable.apple; + } + + /** + * Put the icon on a view, with the right tint or none at all. + * + *

The tint has to be decided here, next to the resource. Two of these drawables are + * single-colour paths that are flattened to whatever colour the screen wants; + * {@code findmy_accessory} is not - it carries its own blues and its own themed greys, and a + * tint over it collapses every path into one colour and produces a featureless blob. So the + * two answers are one decision, and they cannot drift apart. + * + *

Both branches always set the tint, and that is not defensive tidiness. The device + * list is a RecyclerView: a row that showed a Find My accessory is handed straight to the + * next tag, so a version of this that only cleared the tint would leave that row + * painting an Apple logo with no tint at all - invisible in one theme and wrong in the other, + * on whichever rows happened to be recycled. It would look like a scrolling bug. + */ + public static void applyTo(final ImageView view, final BeaconInformation beacon) { + final int icon = forBeacon(beacon); + + view.setImageResource(icon); + view.setImageTintList(tintFor(view.getContext(), icon)); + } + + /** The same, for the one surface that shows this on a button rather than an image. */ + public static void applyTo(final MaterialButton button, final BeaconInformation beacon) { + final int icon = forBeacon(beacon); + + button.setIcon(AppCompatResources.getDrawable(button.getContext(), icon)); + button.setIconTint(tintFor(button.getContext(), icon)); + } + + /** + * {@code colorOutline} for the flat icons, and null for the one that colours itself. + * + *

Resolved from the view's own context so it follows the theme the view is actually in - + * including a context forced to night, which is how this gets rendered both ways in a test + * without touching the device. + */ + private static ColorStateList tintFor(final android.content.Context context, + @DrawableRes final int icon) { + if (icon == R.drawable.findmy_accessory) { + return null; + } + + final TypedValue resolved = new TypedValue(); + context.getTheme().resolveAttribute( + com.google.android.material.R.attr.colorOutline, resolved, true); + + return ColorStateList.valueOf(resolved.data); } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/RecoverableDeviceIcon.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/RecoverableDeviceIcon.java new file mode 100644 index 00000000..e2625955 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/RecoverableDeviceIcon.java @@ -0,0 +1,42 @@ +package dev.wander.android.opentagviewer.ui; + +import java.util.Locale; + +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.python.icloud.RecoverableDevice; + +/** + * Which icon stands for one of the user's Apple devices. + * + *

Chosen from {@code device_model_class} - the record's own word for what kind of thing it is + * - rather than parsed out of the model string. A person picking between two records is choosing + * between physical objects they own, and a phone that looks like a phone is most of that job. + * + *

Matched loosely and case-insensitively on purpose: this is a string from Apple by way of a + * property list, and an unrecognised one should fall back to something sensible rather than draw + * nothing. + */ +public final class RecoverableDeviceIcon { + + private RecoverableDeviceIcon() { + } + + public static int forDevice(final RecoverableDevice device) { + final String kind = (device.getModelClass() + " " + device.getModel()) + .toLowerCase(Locale.ROOT); + + if (kind.contains("ipad")) { + return R.drawable.tablet_24px; + } + if (kind.contains("iphone") || kind.contains("ipod")) { + return R.drawable.smartphone_24px; + } + if (kind.contains("mac") || kind.contains("book")) { + return R.drawable.laptop_24px; + } + + // Anything else - a Watch, an Apple TV, something Apple ships next year. The generic + // devices icon says "a device" honestly rather than guessing wrong. + return R.drawable.devices_24px; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/history/HistoryItemsAdapter.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/history/HistoryItemsAdapter.java index 5bdc8a06..0e687a92 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/history/HistoryItemsAdapter.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/history/HistoryItemsAdapter.java @@ -33,6 +33,7 @@ import dev.wander.android.opentagviewer.R; import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; import dev.wander.android.opentagviewer.db.repo.model.UserSettings; +import dev.wander.android.opentagviewer.util.parse.LocationReportFields; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.schedulers.Schedulers; @@ -108,16 +109,7 @@ public void onBindViewHolder(ViewHolder viewHolder, final int position) { if (this.userSettings.getEnableDebugData() == Boolean.TRUE) { viewHolder.getLocationDetail().setVisibility(VISIBLE); - viewHolder.getLocationDetail().setText(String.format( - Locale.ROOT, - "coords:%.6f,%.6f, desc:%s, status:%d, conf:%d, acc:%d", - item.getLatitude(), - item.getLongitude(), - item.getDescription(), - item.getStatus(), - item.getConfidence(), - item.getHorizontalAccuracy() - )); + viewHolder.getLocationDetail().setText(LocationReportFields.debugText(item)); } else { viewHolder.getLocationDetail().setVisibility(GONE); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MarkerPalette.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MarkerPalette.java new file mode 100644 index 00000000..5aea624c --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MarkerPalette.java @@ -0,0 +1,76 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import android.content.Context; +import android.util.TypedValue; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * What colour a map pin is drawn in. + * + *

The same colours the tag card uses, resolved the same way. A pin and the card that + * describes it sit on screen together, so a theme that tints one and not the other reads as an + * oversight - which is what it was. + * + *

The pins used to be filled with {@code getColor(R.color.md_theme_background)}: the raw + * colour resource rather than the theme attribute the card uses. Those agree exactly, including + * in dark mode, right up until somebody turns on system colours. {@code DynamicColors} rewrites + * the theme so {@code android:colorBackground} becomes a wallpaper-derived tint; it does + * not - and cannot - rewrite {@code @color/md_theme_background}, which is a fixed value in a + * resource file. So the cards took the wallpaper's colour and the pins stayed on the app's own + * palette, and the two drifted apart on exactly the setting somebody enables because they want + * the app to match their phone. + * + *

Resolving the attribute instead is a no-op wherever the two agreed before, and follows the + * card everywhere they did not. It also means anything that themes this app later - a third + * party's palette, a future dynamic scheme - gets the pins for free rather than needing to know + * this file exists. + * + *

Read from the context that is drawing. Not from the application context: a themed or + * night-forced context resolves these differently, which is what lets both be rendered and + * measured in a test without touching the device's settings. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class MarkerPalette { + + /** + * The pin's body, matching the tag card's own background. + * + *

{@code android:colorBackground} rather than a surface colour, because that is what + * {@code maps_tag_card.xml} tints itself with - the point is that they agree, so the answer + * is "whatever the card asked for" rather than a judgement about which is prettier. + */ + @ColorInt + public static int fill(@NonNull final Context context) { + return resolve(context, android.R.attr.colorBackground); + } + + /** + * The icon drawn on the pin, for a tag with no emoji of its own. + * + *

A muted on-surface colour rather than the flat grey it used to be. Material guarantees + * this contrasts with the surface colours around it, which a fixed grey cannot once the fill + * is free to become anything the wallpaper suggests - and an icon that disappears into its + * own pin is the failure this whole change could otherwise introduce. Held to 3:1 by + * {@code MarkerFollowsTheThemeTest}. + */ + @ColorInt + public static int icon(@NonNull final Context context) { + return resolve(context, com.google.android.material.R.attr.colorOnSurfaceVariant); + } + + @ColorInt + private static int resolve(@NonNull final Context context, final int attribute) { + final TypedValue found = new TypedValue(); + + // resolveRefs = true, so a theme pointing the attribute at a colour resource gives the + // colour rather than a reference nobody can paint with. + context.getTheme().resolveAttribute(attribute, found, true); + + return found.data; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/VectorImageGeneratorUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/VectorImageGeneratorUtil.java index 5b4fc311..de722439 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/VectorImageGeneratorUtil.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/VectorImageGeneratorUtil.java @@ -3,6 +3,7 @@ import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Canvas; +import android.graphics.Rect; import android.graphics.Color; import android.graphics.HardwareRenderer; import android.graphics.Paint; @@ -14,6 +15,7 @@ import android.hardware.HardwareBuffer; import android.media.ImageReader; import android.os.Build; +import android.util.Log; import androidx.annotation.ColorInt; import androidx.annotation.DrawableRes; @@ -84,13 +86,26 @@ public static Bitmap makeMarker(@NonNull Resources resources, final String emoji drawMarker(canvas, markerDrawable, markerColor); - // draw emoji on marker (e.g. an emoji like 😁 would be placed in the middle of the marker) + // **Centred on the pin's head, not on the pin.** A location pin is a circle with a point + // hanging off the bottom, so the middle of its bounding box is below the middle of the + // head - and the emoji used to be placed by halving the drawable's height and nudging it + // with two magic divisors, which put it low and slightly left. It was only visible next + // to a non-emoji pin, where the icon is positioned by setBounds and lands correctly. + // + // So both now use the same box, and Paint measures the glyph instead of a constant + // guessing at its width: ALIGN.CENTER handles x, and the font metrics put the glyph's + // visual middle on the box's middle rather than its baseline. + final Rect head = innerIconBounds(canvas, markerDrawable); + Paint paint = new Paint(); paint.setColor(Color.BLACK); paint.setTextSize(EMOJI_TEXT_SIZE); - final float emojiX = (markerDrawable.getIntrinsicWidth()/2.f) - (EMOJI_TEXT_SIZE/1.75f); - final float emojiY = (markerDrawable.getIntrinsicHeight()/2.f) + (EMOJI_TEXT_SIZE/8f); - canvas.drawText(emoji, emojiX, emojiY, paint); + paint.setTextAlign(Paint.Align.CENTER); + + final Paint.FontMetrics metrics = paint.getFontMetrics(); + final float baseline = head.centerY() - (metrics.ascent + metrics.descent) / 2f; + + canvas.drawText(emoji, head.centerX(), baseline, paint); BITMAP_CACHE.put(key, bitmap); return bitmap; @@ -118,7 +133,7 @@ public static Bitmap makeMarker(@NonNull Resources resources, @DrawableRes int i // draw secondary icon on marker (e.g. apple icon) Drawable iconOnMarkerDrawable = Objects.requireNonNull(// TINTED_AFTERWARDS: setTint below replaces every fill, so no theme is needed here. ResourcesCompat.getDrawable(resources, innerIcon, null)); - iconOnMarkerDrawable.setBounds(unit, half + INNER_ICON_OFFSET_TOP, canvas.getWidth() - unit, canvas.getHeight() - (unit + half) + INNER_ICON_OFFSET_TOP); + iconOnMarkerDrawable.setBounds(innerIconBounds(canvas, markerDrawable)); DrawableCompat.setTint(iconOnMarkerDrawable, iconColor); iconOnMarkerDrawable.draw(canvas); @@ -126,6 +141,29 @@ public static Bitmap makeMarker(@NonNull Resources resources, @DrawableRes int i return bitmap; } + /** + * The square inside the pin's head, which is where anything drawn on a pin belongs. + * + *

One definition, used by both kinds of pin. The emoji and the icon are drawn by + * completely different means - {@code drawText} against {@code setBounds} - and while each + * had its own idea of where the middle was, they disagreed: the icon sat in the head and the + * emoji sat low and left of it. Sharing the box is what makes them land in the same place, + * and makes "is it centred" a question with one answer rather than two. + * + *

The numbers are the ones the icon has always used, since that was the one that looked + * right. {@code INNER_ICON_OFFSET_TOP} nudges it up off the point of the pin. + */ + private static Rect innerIconBounds(Canvas canvas, Drawable markerDrawable) { + final int unit = (int) ((float) markerDrawable.getIntrinsicWidth() / 3.5f); + final int half = unit / 2; + + return new Rect( + unit, + half + INNER_ICON_OFFSET_TOP, + canvas.getWidth() - unit, + canvas.getHeight() - (unit + half) + INNER_ICON_OFFSET_TOP); + } + private static void drawMarker(Canvas canvas, Drawable markerDrawable, @ColorInt int markerColor) { // draws the marker itself (in color), on top of the shadow markerDrawable.setBounds(1, 1, canvas.getWidth()-1, canvas.getHeight()-1); @@ -140,7 +178,27 @@ private static Bitmap drawBackgroundForMarker(Drawable markerDrawable) { // https://developer.android.com/guide/topics/renderscript/compute // however this was explicitly deprecated for Android 12 and up // so meh. No blur shadow, just an outline for older versions) - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? drawShadow(markerDrawable) : fromOutline(markerDrawable); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + return drawShadow(markerDrawable); + } catch (final RuntimeException cannotBlur) { + // **A missing shadow is not worth a missing pin.** The blur goes through + // HardwareRenderer and an ImageReader, which needs a working GPU render path - + // and where there is not one, acquireNextImage returns null and this used to + // throw all the way out of showBeaconOnMap. The tag then simply did not appear + // on the map, with an exception in the log about an image, which is a long way + // from "this device cannot blur". + // + // Found on the headless emulator the instrumented tests run on, which has no + // GPU. That is not a real user's phone - but "the device cannot do the fancy + // version" is exactly the case the older-Android branch below already handles, + // and losing the drop shadow is a far better outcome than losing the marker. + Log.w(TAG, "Could not render the marker's blurred shadow on this device;" + + " drawing a plain outline instead", cannotBlur); + } + } + + return fromOutline(markerDrawable); } /** diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/mydevices/DeviceListAdaptor.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/mydevices/DeviceListAdaptor.java index 131bc6aa..47752dce 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/mydevices/DeviceListAdaptor.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/mydevices/DeviceListAdaptor.java @@ -148,7 +148,7 @@ public void onBindViewHolder(ViewHolder viewHolder, final int position) { viewHolder.getItemEmoji().setVisibility(VISIBLE); viewHolder.getItemImage().setVisibility(GONE); } else { - viewHolder.getItemImage().setImageResource(BeaconIcon.forBeacon(beacon)); + BeaconIcon.applyTo(viewHolder.getItemImage(), beacon); viewHolder.getItemImage().setVisibility(VISIBLE); viewHolder.getItemEmoji().setVisibility(GONE); } @@ -166,6 +166,15 @@ public void onBindViewHolder(ViewHolder viewHolder, final int position) { viewHolder.getLastUpdated().setText(this.resources.getString(R.string.last_updated_x, timeAgo)); + } else if (beacon.isIgnored()) { + // **Not the same as "no last location known", though it looks identical.** That + // line describes a tag nobody has walked past this week; this one has been searched + // across months of history and answered nothing, so the app has stopped asking. A + // user staring at the generic line has no way to tell those apart, and the second + // one is the one they can act on - the tag page offers to look again. + viewHolder.getLastUpdated().setText(R.string.tag_ignored_summary); + viewHolder.getWarningIcon().setVisibility(VISIBLE); + } else { viewHolder.getLastUpdated().setText(R.string.no_last_location_known); viewHolder.getWarningIcon().setVisibility(VISIBLE); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BatteryLevelDescription.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BatteryLevelDescription.java new file mode 100644 index 00000000..f66488a1 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BatteryLevelDescription.java @@ -0,0 +1,102 @@ +package dev.wander.android.opentagviewer.util.parse; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import dev.wander.android.opentagviewer.R; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * What the {@code batteryLevel} number on an accessory's record means. + * + *

The record carries a small integer and nothing else, so the debug panel showed "1" - true, + * and useless to anybody who does not already know the scale. This turns it into + * {@code 1 - Full (100%)}: the raw value first, because that is what a bug report should quote + * and what every other source discusses, then the reading. + * + *

The scale, as far as anybody outside Apple knows it: + * + * + * + * + * + * + * + *
0Unknown - nothing has reported on this tag yet
1Full, around 100%
2Medium, roughly 50-70%
3Low, around 20%. This is where iOS starts showing its own warnings
4Critically low, roughly 5-10%. The tag is near the end of its battery
+ * + *

The four state names and their order are Apple's. Its Find My Network Accessory + * Specification (R2, Table 5-5) defines the battery state an accessory advertises as + * {@code 0 = Full, 1 = Medium, 2 = Low, 3 = Critically low} - the same four states in the same + * order, offset by one because this field reserves 0 for "not reported yet". A fresh tag reading 1 + * confirms the offset from the other end. + * + *

The percentages are not. Neither is the claim that iOS warns at 3. Apple's enum is + * four words with no numbers attached, and nobody here has checked those figures against a device + * or a reference implementation - they are repeated knowledge. Said plainly because a comment like + * this is trusted years later by somebody with no way to tell how much work went into it. + * + *

Note also that the specification's field is the byte a beacon broadcasts, which is not + * this one - see {@link LocationReportFields} for that byte, why an AirTag's copy of it does not + * follow the table, and why it is not decoded on sight. + * + *

That is also why the raw number always appears alongside the label. If a reading is wrong, + * the value beside it still is not, and a bug report quoting "3" stays useful to whoever + * disagrees with the word next to it. Anybody who does check this against real tags should + * correct the table above and say so. + * + *

It only means anything for a tag read from an Apple account. The field is updated by + * Apple's own devices as they see the accessory, so a tag imported from a zip carries whatever + * value was true when the export was made and never changes it again - possibly years ago. This + * is why nothing outside the debug panel uses any of it. Anyone who wants to put a battery icon + * on the device list should read this note first, and should probably only do it for account + * tags. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class BatteryLevelDescription { + + /** Apple has not been told yet - a tag nothing has reported on since it was paired. */ + public static final int UNKNOWN = 0; + + /** Full. Confirmable on a fresh tag. */ + public static final int FULL = 1; + + /** Roughly 50-70%. */ + public static final int MEDIUM = 2; + + /** Around 20%, and the point at which iOS begins warning about it itself. */ + public static final int LOW = 3; + + /** Roughly 5-10%: the tag is close to stopping. */ + public static final int VERY_LOW = 4; + + /** + * {@code "1 - Full (100%)"}, or just the number when the value is not one this knows. + * + *

Unrecognised values are shown bare rather than guessed at. A new state, or a field that + * turns out not to be a battery level at all on some accessory, must not be relabelled into + * something that reads as certain. + */ + @NonNull + public static String describe(@NonNull final Context context, final int level) { + final Integer meaning = meaningOf(level); + + if (meaning == null) { + return String.valueOf(level); + } + + return context.getString(R.string.battery_level_described, level, context.getString(meaning)); + } + + private static Integer meaningOf(final int level) { + switch (level) { + case UNKNOWN: return R.string.battery_level_unknown; + case FULL: return R.string.battery_level_full; + case MEDIUM: return R.string.battery_level_medium; + case LOW: return R.string.battery_level_low; + case VERY_LOW: return R.string.battery_level_very_low; + default: 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 e345a183..bcf247fd 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 @@ -164,6 +164,10 @@ public static List parse(final List rawBeaconData .stableIdentifier(List.of(stableIdentifier)) .systemVersion(systemVersion) .vendorId(vendorId) + .fromAccount(beaconData.getOwnedBeaconInfo().fromAccount) + .ignoredAt(beaconData.getOwnedBeaconInfo().ignoredAt) + .fruitlessScans(beaconData.getOwnedBeaconInfo().fruitlessScans) + .lastScanAt(beaconData.getOwnedBeaconInfo().lastScanAt) .ownedBeaconPlistRaw(ownedBeaconPList); if (userOverrides != null) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BeaconNamingRecordInnerParser.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BeaconNamingRecordInnerParser.java index 8c1a3c0a..c6329595 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BeaconNamingRecordInnerParser.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BeaconNamingRecordInnerParser.java @@ -40,6 +40,17 @@ public static Optional extractBeaconNamingRe final String rawBase64String = (String) xPathExpression.evaluate(beaconNamingRecordPlist, XPathConstants.STRING); final String cleanBase64 = cleanXMLBase64Content(rawBase64String); + // **Empty is a real answer, not a failure.** A record read from the Apple account + // carries `cloudKitMetadata` as empty bytes: FindMy.py writes the field as a + // placeholder because CloudKit's own metadata cannot be reconstructed from a + // decrypted record, and the app has no use for it. Handing that to Python is a + // started interpreter, a plistlib.InvalidFileException and a logged stack trace - + // once per tag, every time a list is drawn - describing a state that is entirely + // normal and about which nothing can be done. + if (cleanBase64 == null || cleanBase64.isBlank()) { + return Optional.empty(); + } + // offload this task to python because python has a working implementation. return Optional.ofNullable(PythonUtils.decodeBeaconNamingRecordCloudKitMetadata(cleanBase64)); } catch (Exception e) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/CustomAccessoryParser.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/CustomAccessoryParser.java index 35552c55..fba620c5 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/CustomAccessoryParser.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/CustomAccessoryParser.java @@ -95,6 +95,13 @@ static BeaconInformation parse( .originalEmoji(null) .customAccessory(true) .customAccessoryKeyCount(keyCount) + // Read rather than hardcoded to false. A generated tag arrives in a bundle + // today, but nothing about this parser requires that, and a wrong answer here + // would decide whether the user is allowed to remove it. + .fromAccount(beaconData.getOwnedBeaconInfo().fromAccount) + .ignoredAt(beaconData.getOwnedBeaconInfo().ignoredAt) + .fruitlessScans(beaconData.getOwnedBeaconInfo().fruitlessScans) + .lastScanAt(beaconData.getOwnedBeaconInfo().lastScanAt) // Not an Apple product, so it has no product or vendor id to report and no // battery it can tell us about. Zero rather than a guess. .productId(0) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/LocationReportFields.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/LocationReportFields.java new file mode 100644 index 00000000..669c8381 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/LocationReportFields.java @@ -0,0 +1,254 @@ +package dev.wander.android.opentagviewer.util.parse; + +import androidx.annotation.NonNull; + +import java.util.Locale; + +import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * The three raw numbers on a location report, rendered for the debug toggle. + * + *

A row used to read {@code status:144, conf:0, acc:83} and stop there, which is true and tells + * nobody anything. This adds what can be cited and deliberately stops short of what cannot - the + * line between those two is the point of this class, so it is drawn explicitly below. + * + *

The source throughout is Heinrich, Stute, Kornhuber and Hollick, Who Can Find My Devices? + * Security and Privacy of Apple's Crowd-Sourced Bluetooth Location Tracking System, PoPETs + * 2021(3), arXiv:2103.02282 - the SEEMOO paper that reverse-engineered offline finding. Section + * numbers below are that paper's. + * + *

Where the fields come from

+ * + *

Fig. 2 of the paper gives the report layout, and the pinned FindMy.py parses exactly it: + * + *

+ *   Timestamp   Confidence   Ephemeral public key   Encrypted location   AES-GCM tag
+ *    4 bytes      1 byte           57 bytes              10 bytes          16 bytes
+ *                                                            |
+ *                              Latitude 4 | Longitude 4 | Horizontal accuracy 1 | Status 1
+ * 
+ * + *

Those add to 88, which is the payload length {@code findmy/reports/reports.py} branches on. + * A second, longer format exists - macOS 14 added a byte, and the parser drops it - so the byte + * offsets are worth re-reading if the pin ever moves. + * + *

Confidence sits outside the encryption. It is ahead of the ephemeral key in the + * figure, and the AES-GCM tag covers only the ten encrypted bytes; the parser agrees, deriving the + * key from {@code encrypted_data[1:58]} and authenticating {@code [58:]} while confidence is + * {@code encrypted_data[0]}. So Apple's server could return any value in that byte and nothing + * here or anywhere else would detect it. Accuracy and status are inside the tag, so those two + * genuinely came from whichever iPhone heard the tag. + * + *

Horizontal accuracy: metres, and not the metres people assume

+ * + *

The paper is careful about the unit rather than assertive, and this note copies that: "We + * assume that the accuracy value is encoded in metric meters as it matches the experimentally + * determined positioning error of the coordinates in the location reports" (§ 6.3, footnote 3). + * + *

It describes the finder's own position, not the tag's. Some stranger's iPhone reports + * how well it knew where it was when it overheard the beacon. It says nothing about how far + * the tag was from that phone. + * + *

And § 7.1 measured the byte against a GPS ground truth, which is worth knowing before + * trusting it (Table 6, mean distance in metres): + * + * + * + * + * + * + * + *
ScenarioReportedActually
Walking121.981.4
Restaurant117.260.2
Train171.0440.7
Car145.2580.7
+ * + *

So it is pessimistic for a tag sitting still and badly optimistic for one in motion - + * 145 m claimed where the truth was 581 m. It is a hint, not an error bar, and drawing it as a + * confident radius on a map would be a lie in exactly the case a user cares most about. + * + *

Being one byte, it also saturates: 255 means "255 or worse". + * + *

Status: decoded only when the byte says it can be

+ * + *

The accessory's own status byte - byte 12 of its BLE advertisement (paper, Table 2), copied + * through by the finder. The paper labels it {@code Status (e.g., battery level)} and defers to + * Apple's Find My Network Accessory Specification. That document does define it, in + * Table 5-5, "Payload for separated state", byte 2: + * + *

+ *   Bits 0-1: Reserved.
+ *   Bit 2:    Maintained
+ *   Bits 3-4: Reserved
+ *   Bits 5:   0b1
+ *   Bits 6-7: Battery state.
+ *
+ *   Maintained: Set if owner connected within current key rotation period (15 minutes)
+ *   Battery state definition: 0 = Full, 1 = Medium, 2 = Low, 3 = Critically low
+ * 
+ * + *

Which settles it for accessories that follow the specification, and disposes of the bitmasks + * in circulation. {@code go-haystack} is half right - its {@code 0x40}/{@code 0x80}/{@code 0xC0} + * are exactly medium/low/critical shifted into bits 6-7, and its {@code 0x10} for "full" should + * have been {@code 0x20}. The tables a chatbot will produce - {@code 0x80} paired, {@code 0x02} + * sound playing, {@code 0x04} motion detected - are invention. That is why this class exists. + * + *

But an AirTag does not follow that table, and this app mostly sees AirTags. The value + * it observes is {@code 0x90}: bit 5 clear where the specification requires it set, and reserved + * bit 4 set. Adam Catley's teardown records a real AirTag advertising {@code 0x10}, which breaks + * the same two rules. The specification governs third-party MFi accessories; AirTag is Apple's own + * hardware and predates it. Decoding {@code 0x90} against Table 5-5 anyway yields "battery Low" + * for a tag whose own record reads Full - a confident, wrong answer, which is worse than none. + * + *

And the byte is not trustworthy even when it is well-formed. Caesar Creek Software's + * write-up of this network puts it plainly: "it's supposed to indicate the battery level and + * device type, but the user can actually set it to whatever they want". They also record that + * marking a beacon as an Apple Device or Find My Device through this byte suppresses + * unwanted-tracking alerts - so those "Reserved" bits carry a device type in practice, and a + * beacon has an active reason to lie about them. + * + *

The likely real answer is that there is no single layout. An AirTag, an older Apple + * device and a third-party gadget can each use these bits differently and all be working + * correctly, in which case reading them properly means a routing table keyed by accessory type + * rather than one decoder. Nobody has built that, and doing it honestly would mean sitting down + * with several kinds of tag at several battery levels and writing down what each one emits. The + * gate below exists so that the app stays useful and silent in the meantime, instead of guessing. + * + *

So {@link #status(long)} decodes only a byte that actually conforms to Table 5-5 - bit 5 set + * and every reserved bit clear - and otherwise shows the number alone. A conforming byte is + * annotated as what the beacon claimed, never as a measurement. Every value carries decimal, + * hex and binary regardless, because those three are certainly right and the binary is what + * somebody correlating this against their own tags actually needs. + * + *

Not translated, matching the rest of the debug output - a translated bit pattern helps + * nobody. + * + *

Sources

+ * + *

Linked because every one of them was hard to find, and because the next person to doubt a + * sentence above should be able to check it rather than re-derive it: + * + *

    + *
  • Heinrich, Stute, Kornhuber and Hollick, Who Can Find My Devices?, PoPETs 2021(3) - + * arXiv:2103.02282, + * PDF. Fig. 2 for + * the report layout, Table 2 for the advertisement, § 6.3 fn. 3 for the accuracy unit, + * Table 6 for its measured error.
  • + *
  • Apple, Find My Network Accessory Specification, release R2, 2022-05-17 - + * + * mirror. Table 5-5 for the status byte. The primary source, and MFi-gated, so the + * mirror is the only way most people will read it.
  • + *
  • Caesar Creek Software, Find My and Find Hub Network Research - + * cc-sw.com. That the + * byte is beacon-controlled, and that a device type in it suppresses stalking alerts.
  • + *
  • Adam Catley, AirTag Reverse Engineering - + * adamcatley.com. A real + * AirTag observed advertising {@code 0x10}.
  • + *
  • go-haystack + * - the half-right battery constants, kept here as the example of what this class is for.
  • + *
+ */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class LocationReportFields { + + /** One byte, so this is the ceiling rather than a reading. */ + static final long ACCURACY_SATURATES_AT = 255; + + /** The two lines under a history row when the debug toggle is on. */ + @NonNull + public static String debugText(@NonNull final BeaconLocationReport report) { + return String.format( + Locale.ROOT, + "%.6f,%.6f · %s%n%s · %s · %s", + report.getLatitude(), + report.getLongitude(), + report.getDescription(), + accuracy(report.getHorizontalAccuracy()), + confidence(report.getConfidence()), + status(report.getStatus())); + } + + /** + * {@code "acc 83 m"}, or {@code "acc ≥255 m"} at the ceiling. + * + *

The unit is spelled out because a bare number invites being read as a distance to the + * tag, which it is not. + */ + @NonNull + public static String accuracy(final long metres) { + if (metres >= ACCURACY_SATURATES_AT) { + return "acc ≥" + ACCURACY_SATURATES_AT + " m"; + } + return "acc " + metres + " m"; + } + + /** {@code "conf 0"}. Left bare - see the note above on it being unauthenticated. */ + @NonNull + public static String confidence(final long value) { + return "conf " + value; + } + + /** Table 5-5 requires this set. An AirTag leaves it clear, which is how they are told apart. */ + private static final long SPEC_MARKER_BIT = 0b0010_0000; + + /** Bits 0-1 and 3-4. Publicly "Reserved"; a device type rides in them in practice. */ + private static final long SPEC_RESERVED_BITS = 0b0001_1011; + + /** Bit 2: the owner device connected within the current 15-minute key rotation period. */ + private static final long SPEC_MAINTAINED_BIT = 0b0000_0100; + + /** Bits 6-7, as an index into {@link #SPEC_BATTERY_STATES}. */ + private static final int SPEC_BATTERY_SHIFT = 6; + + /** Apple's words, in Apple's order (Table 5-5). */ + private static final String[] SPEC_BATTERY_STATES = {"full", "medium", "low", "critically low"}; + + /** + * {@code "status 144 = 0x90 = 0b10010000"}, plus a reading when the byte earns one. + * + *

Decimal, hex and binary always: three renderings of one number, every one certainly true, + * and the binary is what somebody correlating bits against their own tags needs. + * + *

The Table 5-5 reading is appended only to a byte that conforms to Table 5-5 - the + * marker bit set and every reserved bit clear. An AirTag satisfies neither, so the value this + * app usually sees stays a bare number rather than being told it has a low battery when it + * does not. And a conforming byte is phrased as a claim, because a beacon sets this field + * itself and can put anything in it. + * + *

A value too wide for a byte is shown at its natural width rather than truncated to eight + * bits: it would mean an assumption is wrong somewhere upstream, and hiding the evidence would + * be the worst response to that. + */ + @NonNull + public static String status(final long value) { + if (value < 0) { + return "status " + value; + } + + final String bits = Long.toBinaryString(value); + final String padded = value <= 0xFF + ? "00000000".substring(bits.length()) + bits + : bits; + + return String.format(Locale.ROOT, "status %d = 0x%02X = 0b%s%s", + value, value, padded, specReading(value)); + } + + /** {@code " (claims battery full, maintained)"}, or empty when the byte does not conform. */ + @NonNull + private static String specReading(final long value) { + final boolean conforms = value <= 0xFF + && (value & SPEC_MARKER_BIT) != 0 + && (value & SPEC_RESERVED_BITS) == 0; + + if (!conforms) { + return ""; + } + + final String battery = SPEC_BATTERY_STATES[(int) (value >> SPEC_BATTERY_SHIFT) & 0b11]; + final String maintained = (value & SPEC_MAINTAINED_BIT) != 0 + ? "maintained" : "not maintained"; + + return " (claims battery " + battery + ", " + maintained + ")"; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/NamingRecordEditor.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/NamingRecordEditor.java new file mode 100644 index 00000000..ea033d6e --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/NamingRecordEditor.java @@ -0,0 +1,127 @@ +package dev.wander.android.opentagviewer.util.parse; + +import androidx.annotation.NonNull; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.io.StringWriter; + +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Put a new name or emoji into a stored naming record. + * + *

Only ever run after iCloud has accepted the same change. The naming record is what + * {@link BeaconDataParser} reads a tag's real name and emoji out of, so this is what makes the + * screen agree with the account instead of covering it with a nickname. A nickname would have + * been far less code and quietly wrong: it wins at display time forever, so the next time + * somebody renamed the tag on their iPhone the app would go on showing the old local value and + * look like it had stopped syncing. + * + *

Edited rather than rebuilt. The record carries a good deal this app never reads - + * {@code cloudKitMetadata} above all - and a document reconstructed from the handful of fields + * that are understood would silently drop the rest. Only the one value moves; everything else is + * the bytes that were there before. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class NamingRecordEditor { + + /** + * A copy of {@code plistXml} with the given values replaced. + * + * @param name the new name, or null to leave it alone. + * @param emoji the new emoji, or null to leave it alone. + * @throws BeaconDataParsingException if the record cannot be read or written back. + */ + @NonNull + public static String with(@NonNull final String plistXml, + final String name, + final String emoji) { + try { + final Document document = XmlParser.parse(plistXml); + + if (name != null) { + set(document, "name", name); + } + if (emoji != null) { + set(document, "emoji", emoji); + } + + return serialise(document); + } catch (final Exception e) { + throw new BeaconDataParsingException("Could not write the new name into the record", e); + } + } + + /** + * Set one {@code kv} pair, adding it if it is not there. + * + *

Adding it matters. CloudKit holds no emoji for an accessory nobody has ever given + * one, so the first emoji somebody picks is an insert rather than an edit - and a version of + * this that only replaced would silently do nothing for exactly that tag. + */ + private static void set(final Document document, final String key, final String value) { + final NodeList keys = document.getElementsByTagName("key"); + + for (int i = 0; i < keys.getLength(); i++) { + final Node candidate = keys.item(i); + if (!key.equals(candidate.getTextContent())) { + continue; + } + + final Node existing = nextElement(candidate); + if (existing != null) { + existing.setTextContent(value); + return; + } + } + + final Node dictionary = document.getElementsByTagName("dict").item(0); + if (dictionary == null) { + throw new BeaconDataParsingException("The record has no dict to add " + key + " to"); + } + + final Element addedKey = document.createElement("key"); + addedKey.setTextContent(key); + + final Element addedValue = document.createElement("string"); + addedValue.setTextContent(value); + + dictionary.appendChild(addedKey); + dictionary.appendChild(addedValue); + } + + /** The next element after a node, skipping the whitespace a pretty-printed plist is full of. */ + private static Node nextElement(final Node after) { + for (Node node = after.getNextSibling(); node != null; node = node.getNextSibling()) { + if (node.getNodeType() == Node.ELEMENT_NODE) { + return node; + } + } + return null; + } + + private static String serialise(final Document document) throws Exception { + final Transformer transformer = TransformerFactory.newInstance().newTransformer(); + // The DOCTYPE the record came with, put back by hand: a Transformer drops it otherwise, + // and the result would no longer look like the plists everything else here reads. + transformer.setOutputProperty(OutputKeys.DOCTYPE_PUBLIC, "-//Apple//DTD PLIST 1.0//EN"); + transformer.setOutputProperty( + OutputKeys.DOCTYPE_SYSTEM, "http://www.apple.com/DTDs/PropertyList-1.0.dtd"); + + final StringWriter written = new StringWriter(); + transformer.transform(new DOMSource(document), new StreamResult(written)); + + return written.toString(); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/rx/AccountReadPolicy.java b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/AccountReadPolicy.java new file mode 100644 index 00000000..3a44a873 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/AccountReadPolicy.java @@ -0,0 +1,108 @@ +package dev.wander.android.opentagviewer.util.rx; + +/** + * Whether now is a good moment to re-read the Apple account. + * + *

A different question from {@link RefreshPolicy}, and much less often. That one asks + * whether to fetch locations, which is what the map is for and happens every minute. This + * asks whether to re-read the account - which tags exist, what they are called, which have + * gone - and that changes when somebody adds a tag in Find My or renames one, which is rare and + * never urgent. + * + *

Cheap is not the same as free. A read decrypts every record on the account, and every + * call into Python is serialised behind the location fetches - where a single accessory with no + * key alignment record can run for minutes. So a read that fires too eagerly does not just waste + * work: it sits in a queue in front of the thing the user is actually looking at. + * + *

Takes the time rather than reading a clock, so the timing can be tested without waiting. + */ +public final class AccountReadPolicy { + + /** Why a tick did or did not re-read, so the log says something useful. */ + public enum Decision { + /** Nobody has linked an account, so there is nothing to read. */ + NOT_LINKED, + /** Python is busy - almost always a location fetch, which can run for minutes. */ + BUSY, + /** Read recently enough. */ + TOO_SOON, + /** Go. */ + READ; + + public boolean shouldRead() { + return this == READ; + } + + public String reason() { + switch (this) { + case NOT_LINKED: return "no Apple account is linked"; + case BUSY: return "Python is busy with another fetch"; + case TOO_SOON: return "the account was read recently"; + default: return "it is time"; + } + } + } + + private final long minIntervalMillis; + + /** + * Zero means "never read", which is the honest starting state and not "read at the epoch". + * A first tick after linking therefore reads, which is what somebody expects to happen + * shortly after they connect an account. + */ + private long lastReadAt = 0L; + + public AccountReadPolicy(final long minIntervalMillis) { + this.minIntervalMillis = minIntervalMillis; + } + + /** + * @param now the wall clock, passed in + * @param linked whether a keychain membership is stored + * @param busy whether a call into Python is already running + */ + public Decision decide(final long now, final boolean linked, final boolean busy) { + if (!linked) { + return Decision.NOT_LINKED; + } + // **Checked before the interval, not after.** Calls into Python are serialised, so a + // read that decides to go while a location fetch is running does not wait its turn + // politely - it takes the lock as soon as that fetch releases it, in front of whatever + // the user did next. Skipping costs one interval; the account is not going anywhere. + if (busy) { + return Decision.BUSY; + } + if (this.hasEverRead() && now < this.lastReadAt + this.minIntervalMillis) { + return Decision.TOO_SOON; + } + return Decision.READ; + } + + /** + * Records a read. + * + * @param at the time the read started, so a long read does not immediately earn + * another one the moment it finishes. + */ + public void markRead(final long at) { + this.lastReadAt = at; + } + + public boolean hasEverRead() { + return this.lastReadAt > 0L; + } + + public long lastReadAt() { + return this.lastReadAt; + } + + /** + * Forget that anything was ever read. + * + *

For unlinking: if an account is linked again, the first tick should read it rather than + * wait out an interval measured against somebody else's account. + */ + public void forget() { + this.lastReadAt = 0L; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/rx/ScanOrder.java b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/ScanOrder.java new file mode 100644 index 00000000..69ae48fc --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/ScanOrder.java @@ -0,0 +1,98 @@ +package dev.wander.android.opentagviewer.util.rx; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; + +/** + * What order to ask about tags in, on a scheduled fetch. + * + *

Order matters because the batch is sequential and can be abandoned. One accessory is + * fetched at a time - Python's account drives a single event loop - and a tag with no key + * alignment record can take minutes on its own. So a batch is rarely finished in one sitting: the + * user closes the app, or the screen goes away, and whatever was at the end never got asked. + * + *

A fixed order therefore does not distribute that cost, it concentrates it. Whichever tag + * sorts last is the one that is always last, every time, for as long as the app is installed - + * and it is the one whose location is permanently stalest through no property of its own. + * + *

Two rules, in this order: + * + *

    + *
  1. Tags that answered last time go first. They are the cheap ones - alignment is + * narrow, so each is a request or two - and they are the ones whose rows visibly change. + * Putting them first means the screen finishes updating early rather than after the + * expensive silent tags have been ground through.
  2. + *
  3. Within every group, shuffle. This is what stops any individual tag being + * permanently last.
  4. + *
+ * + *

Tags nobody has ever scanned sit between the two: there is no evidence about them either + * way, so they should not be made to wait behind known-silent ones, and they have not earned a + * place ahead of tags known to be answering. On a fresh install every tag is in that middle + * group, which makes the whole batch simply random - the right answer when nothing is known. + * + *

Takes its {@link Random} rather than making one, so a test can ask what the order is + * instead of only what it is not. + */ +public final class ScanOrder { + + private ScanOrder() { + } + + /** One tag, in the two facts that decide where it goes. */ + public static final class Candidate { + private final String beaconId; + private final boolean everScanned; + private final boolean lastScanFoundSomething; + + public Candidate(@NonNull final String beaconId, final boolean everScanned, + final boolean lastScanFoundSomething) { + this.beaconId = beaconId; + this.everScanned = everScanned; + this.lastScanFoundSomething = lastScanFoundSomething; + } + + public String getBeaconId() { + return this.beaconId; + } + } + + /** + * The ids to ask about, in the order to ask. + * + * @param random supplied so the shuffle can be pinned in a test. + */ + @NonNull + public static List forScheduledFetch( + @NonNull final List candidates, @NonNull final Random random) { + + final List answering = new ArrayList<>(); + final List unknown = new ArrayList<>(); + final List silent = new ArrayList<>(); + + for (final Candidate candidate : candidates) { + if (!candidate.everScanned) { + unknown.add(candidate.beaconId); + } else if (candidate.lastScanFoundSomething) { + answering.add(candidate.beaconId); + } else { + silent.add(candidate.beaconId); + } + } + + Collections.shuffle(answering, random); + Collections.shuffle(unknown, random); + Collections.shuffle(silent, random); + + final List order = new ArrayList<>(candidates.size()); + order.addAll(answering); + order.addAll(unknown); + order.addAll(silent); + + return order; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/rx/WideScanBackoff.java b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/WideScanBackoff.java new file mode 100644 index 00000000..c6095030 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/WideScanBackoff.java @@ -0,0 +1,78 @@ +package dev.wander.android.opentagviewer.util.rx; + +import java.util.concurrent.TimeUnit; + +/** + * How often to keep asking about a tag that has said nothing. + * + *

The expensive tags are the silent ones. A tag with no key alignment record searches + * from its pairing date, at a request per ~290 keys - so where a healthy tag costs one request, + * one that nobody has walked past costs hundreds, and it costs them again on every single + * refresh. Left alone, the app spends most of its conversation with Apple on the tags least + * likely to answer, which is the account-flagging risk of rule 6 arriving through the back door. + * + *

So a tag earns its next attempt. The first few silences are ordinary - a tag in a + * drawer for a fortnight is a normal tag - so the backoff starts short and only becomes long once + * the silence has. It caps rather than growing forever, because a tag can always come back: a + * bike found, a jacket out of storage, and the app should notice within a day. + * + *

This governs the automatic fetches and nothing else. A person who opens a tag and + * asks for it gets a search, every time, however long it has been quiet - they may have just + * found the bike. Backing off a button somebody pressed would look like the app ignoring them, + * which is the failure this whole area is meant to avoid rather than reproduce. So this is + * consulted where the periodic request list is built, never on a user-initiated refresh. + * + *

Pure and clock-free: it takes the time rather than reading one, so the schedule can be + * tested without waiting a week. + */ +public final class WideScanBackoff { + + /** + * What each consecutive fruitless search buys, in minutes. + * + *

Sized against what silence means rather than against a formula. The first two entries + * are "no delay at all" - two misses is not evidence of anything, and a tag that has just + * been imported deserves a proper try. After that it lengthens quickly, and stops at a day + * so nothing is ever unreachable for longer than that without somebody asking. + */ + private static final long[] SCHEDULE_MINUTES = {0, 0, 15, 60, 4 * 60, 12 * 60, 24 * 60}; + + private WideScanBackoff() { + } + + /** + * How long to wait after {@code fruitlessScans} searches that found nothing. + * + * @param fruitlessScans consecutive searches that came back empty; 0 means it last worked. + */ + public static long waitMillisAfter(final int fruitlessScans) { + if (fruitlessScans <= 0) { + return 0; + } + + final int step = Math.min(fruitlessScans, SCHEDULE_MINUTES.length - 1); + return TimeUnit.MINUTES.toMillis(SCHEDULE_MINUTES[step]); + } + + /** + * Whether this tag is due to be searched for again. + * + * @param now the wall clock, passed in + * @param fruitlessScans consecutive searches that found nothing + * @param lastScanAt when it was last searched for, or null if never + */ + public static boolean isDue(final long now, final int fruitlessScans, final Long lastScanAt) { + if (lastScanAt == null) { + // Never tried. Always worth one attempt - this is how a tag imported five minutes + // ago gets looked for at all. + return true; + } + + return now >= lastScanAt + waitMillisAfter(fruitlessScans); + } + + /** The longest anything is ever left alone, for a test and for a log line to quote. */ + public static long longestWaitMillis() { + return TimeUnit.MINUTES.toMillis(SCHEDULE_MINUTES[SCHEDULE_MINUTES.length - 1]); + } +} diff --git a/app/src/main/python/icloud_bridge.py b/app/src/main/python/icloud_bridge.py index a88d162c..b5ae69c1 100644 --- a/app/src/main/python/icloud_bridge.py +++ b/app/src/main/python/icloud_bridge.py @@ -20,13 +20,21 @@ "message": ...}`, and the `reason` is what Java branches on so the wording stays in `strings.xml` where it can be translated. -**Nothing here writes to the account.** Recovery unwraps shares to a key; it does not enrol this -device as a peer, sign anything, or create a record. That is a deliberate line, and the moment -something crosses it this docstring should stop saying so. +**Everything here reads, except :meth:`ICloudSession.join`.** Recovery unwraps shares to a key and +creates nothing. `join` is the one call that writes: it enrols an escrow record and adds this app +to the account's keychain as a member in its own right. + +That is worth the write for one reason. A non-member reads with view keys it holds a share of, +and those keep working - until the view keys **roll**, which is expected whenever the circle's +membership changes. Only a current member receives shares of the new ones, so a non-member goes +quietly stale: still holding keys, still looking fine, decrypting nothing new. Here that surfaces +as a map that stopped updating for no reason, which is the failure shape of issues #43 and #119 +and the one users cannot diagnose for themselves. """ from __future__ import annotations +import base64 import json import plistlib import traceback @@ -34,7 +42,10 @@ import identity as app_identity from exporter import icloud +from findmy.keychain.enrolment import DeviceDescription +from findmy.keychain.join import JoinedPeer from findmy.keychain.recovery import RecoveryError +from findmy.keychain.session import KeychainSessionError REASON_NOT_SIGNED_IN = "not_signed_in" """The account handed over is not in a state that can talk to iCloud.""" @@ -60,6 +71,18 @@ REASON_PASSCODE_REJECTED = "passcode_rejected" """The escrow service did not accept the passcode. **Not proof it was wrong** - see below.""" +REASON_NOT_UNLOCKED = "not_unlocked" +"""A join was asked for before anything unlocked, so there is no peer to sponsor it.""" + +REASON_MEMBERSHIP_UNUSABLE = "membership_unusable" +""" +The stored membership no longer reads the keychain. + +**Not a broken app.** The peer may have been removed from the account - which is how a +user revokes this app - so the answer is to unlock with a passcode again and join afresh, +not to retry with the same stored keys. +""" + REASON_NO_SUCH_RECORD = "no_such_record" """The serial Java asked to unlock with is not in the list it was given.""" @@ -67,6 +90,17 @@ """An id was asked for that this session never fetched, or has not fetched since reopening.""" +REASON_NOT_AN_ACCESSORY = "not_an_accessory" +""" +The thing being renamed is one of the owner's own devices, not an accessory. + +**An iPhone, iPad or Mac takes its name from more places than the naming record**, so writing one +here would change what Find My shows for it in one place and leave every other copy saying +something else. The app nicknames those locally instead, and keeps showing the real name +alongside. Only an accessory - an AirTag, or a Find My-certified tag somebody made - has the +naming record as its single source of truth. +""" + REASON_UNKNOWN = "unknown" """Anything else, with the exception text carried through so a report can be answered.""" @@ -84,6 +118,18 @@ """ +def _toUnixEpochMs(when: Any) -> int | None: + """A datetime as milliseconds, or None. Formatting a date is Java's job, not this one's.""" + if when is None: + return None + + try: + return int(when.timestamp() * 1000) + except (AttributeError, OSError, OverflowError, ValueError): + # A record with an unrepresentable date is still a record worth offering. + return None + + def _failure(reason: str, message: str) -> str: return json.dumps({"ok": False, "reason": reason, "message": message}) @@ -141,6 +187,8 @@ def __init__(self, account: Any, asyncAccount: Any, loop: Any) -> None: self._loop = loop self._client: Any = None self._records: list[Any] = [] + # The peer an unlock recovered, kept because it is what sponsors a join. + self._sponsor: Any = None self._candidates: dict[str, Any] = {} def open(self) -> str: @@ -201,7 +249,21 @@ def recoveryOptions(self) -> str: "devices": [ { "serial": record.serial, + # FindMy.py's own sentence, kept as the honest fallback for anything the + # screen cannot lay out itself. "description": record.describe(), + # The parts, so the screen can build a tile rather than print a sentence. + # **`name` is the user's own and is often empty** - somebody who never + # renamed their phone has none - so the screen falls back to the class, + # which is a real word, rather than to "unnamed device". + "name": record.device_name or "", + "model": record.device_model or "", + # "iPhone", "iPad", "Mac" and so on: what picks the icon. + "modelClass": record.device_model_class or "", + # **When the record was created, not when the device was last used.** + # Labelling it "last used" would be a straight lie: a record escrowed three + # months ago says nothing about whether the phone was used this morning. + "escrowedAtMs": _toUnixEpochMs(record.escrowed_at), } for record in self._records ], @@ -235,7 +297,13 @@ def unlock(self, serial: str, passcode: str) -> str: f"No recoverable device in this session has the serial {serial!r}.") try: - self._loop.run_until_complete(self._client.unlock(record, passcode)) + # Recovered explicitly rather than through `client.unlock`, which does the same two + # steps and keeps the peer to itself. The peer is what `join` sponsors, and joining + # is the whole reason this app unlocks at all - so it has to survive the call. + peer = self._loop.run_until_complete( + self._client.session.recover(record, passcode)) + self._loop.run_until_complete(self._client.resume(peer)) + self._sponsor = peer return json.dumps({"ok": True}) except RecoveryError as e: @@ -250,6 +318,110 @@ def unlock(self, serial: str, passcode: str) -> str: # Not this module's to hold a moment longer than the call needs it. del passcode + def join(self, escrowPasscode: str) -> str: + """ + Become a member of the account's keychain in this app's own right. + + **Why this is worth an irreversible call.** Without it the app reads with keys it holds a + share of as a non-member. Those keep working - they are the keychain's view keys, not the + sponsoring device's - right up until the view keys **roll**, which is expected whenever + the circle's membership changes. Only a current member is given shares of the new ones, so + a non-member goes quietly stale: still holding keys, still looking fine, decrypting + nothing new. In this app that surfaces as a map that stopped updating for no reason, which + is the failure shape of issues #43 and #119 and the one users cannot diagnose. + + **`escrowPasscode` is not the user's.** It is the passcode *this app's own* record will be + recoverable under - generated by `EscrowPasscode`, 256 bits, never shown to anybody. The + one the user typed recovered the sponsor and is already gone. + + **Never call this twice for one intent.** A response that will not decode is not a call + that failed, and a timeout does not establish that nothing was sent - FindMy.py raises + with `JOIN_HAPPENED` for exactly that, and the recovery is to sync the directory, not to + try again. Java must treat any failure here as "it may have happened". + + Returns the membership to persist. **The keys in it are the only copy in existence**: lose + them and the peer is stranded in the account with no way to use or remove it, so the + caller must store it before anything else can go wrong. + """ + if self._client is None: + return _failure(REASON_NOT_SIGNED_IN, "The Find My client is not open.") + + if self._sponsor is None: + return _failure( + REASON_NOT_UNLOCKED, + "Nothing has been unlocked in this session, so there is no peer to sponsor a" + " join.") + + if not escrowPasscode: + # Enrolment refuses this anyway - "a record enrolled under an empty passcode could be + # recovered by anyone" - but failing here says which side got it wrong. + return _failure( + REASON_UNKNOWN, "No passcode was supplied for this app's own escrow record.") + + try: + identity = self._async.identity + outcome = self._loop.run_until_complete(self._client.session.join( + self._sponsor, + passcode=escrowPasscode, + # **Read, not composed** - rule 11. The same identity reaches the escrow record's + # metadata and the peer's stable info, both of which a person reads in a listing, + # and a path that invents its own makes one client look like several. + device=DeviceDescription( + name=app_identity.APP_CLOUDKIT_DEVICE_NAME, + model=identity.model, + serial=self._async.serial, + build=identity.os_build, + ), + os_version=identity.os_version, + )) + + print(f"iCloud bridge: joined as {outcome.peer.peer_id}, " + f"{outcome.shares} view key(s) re-addressed") + + return json.dumps({ + "ok": True, + "peer": outcome.peer.to_json(), + # Kept alongside the membership, and not instead of it. The membership is how a + # refresh avoids a passcode; the entropy plus the passcode above is how the peer + # is recovered through escrow if the app's encrypted store is ever destroyed. + # Neither substitutes for the other. + "entropy": base64.b64encode(outcome.bottle.entropy).decode("ascii"), + "label": outcome.label, + "shares": outcome.shares, + }) + except Exception: + return _unexpected("joining the account's keychain") + finally: + del escrowPasscode + + def resume(self, peerJson: str) -> str: + """ + Read the keychain as the member this app already is - no passcode, nothing borrowed. + + This is what the join bought. Every method that takes a peer wants only its id and its two + private keys, which is what a stored membership carries. + """ + if self._client is None: + return _failure(REASON_NOT_SIGNED_IN, "The Find My client is not open.") + + try: + peer = JoinedPeer.from_json(json.loads(peerJson)) + self._loop.run_until_complete(self._client.resume(peer)) + + print(f"iCloud bridge: reading as {peer.peer_id}, with no passcode") + + return json.dumps({"ok": True}) + except Exception: + # Worth its own reason. A membership that no longer works is not a broken app - the + # peer may have been removed from the account - and the answer is to unlock with a + # passcode again, not to retry this. + detail = traceback.format_exc() + print(f"iCloud bridge: could not read as the stored member:\n{detail}") + + return _failure( + REASON_MEMBERSHIP_UNUSABLE, + detail.strip().splitlines()[-1] if detail.strip() else "resume failed") + def fetch(self) -> str: """ Read and decrypt the account's accessories, and describe what is there. @@ -352,6 +524,61 @@ def records(self, selectionJson: str) -> str: return json.dumps({"ok": True, "accessories": accessories}) + def rename(self, beaconId: str, plistXml: str, name: str, emoji: str) -> str: + """ + Change an accessory's name and emoji in iCloud. + + **The second call here that writes**, and unlike :meth:`join` it changes something the + owner sees in Find My on their own devices. FindMy.py sends back the rest of the record + unchanged, so nothing but these two fields moves. + + **Refused for the owner's own devices**, and the refusal is made here rather than trusted + to the caller. An iPhone, iPad or Mac carries its name in more places than this record; + writing one would leave Find My showing a name that disagrees with the device itself. The + test is :func:`opentagviewer_export.hardware.is_own_device`, which is the same one the + exporter uses to decide that handing over a Mac's keys is a different act from handing + over an AirTag's - two signals, an Apple model identifier or the shared secret only a + device carries, and neither is a thing to re-derive in Java. + + **Judged from the stored record rather than by fetching.** Java already holds the + `OwnedBeacons` plist this accessory was imported with - it is the same document a bundle + carries - so the alternative is reading the whole account to answer a question the + record on disk already answers. + + :param beaconId: The accessory's identifier, which is what `associatedBeacon` names. + :param plistXml: Its stored `OwnedBeacons` plist, to decide what kind of thing it is. + :param name: The new name, or empty to leave it alone. + :param emoji: The new emoji, or empty to leave it alone. + """ + if self._client is None: + return _failure(REASON_NOT_SIGNED_IN, "The Find My client is not open.") + + # Empty means "leave it alone", not "set it to empty" - sending both fields every time + # would blank the emoji of every accessory anybody renamed. + changes = {field: value for field, value in (("name", name), ("emoji", emoji)) if value} + + if not changes: + return _failure(REASON_UNKNOWN, "Nothing to change: pass a name, an emoji, or both.") + + refusal = _refuseToRenameADevice(plistXml) + if refusal is not None: + return refusal + + try: + self._loop.run_until_complete( + self._client.rename(beaconId, **changes)) + except KeychainSessionError: + # Distinguished from the catch-all because the answer is different: this is not a + # broken rename, it is a session that never got its keys, and the app can fix it by + # resuming or unlocking rather than by telling the user something went wrong. + return _failure( + REASON_NOT_UNLOCKED, + "The keychain is not open, so nothing can be written to the account yet.") + except Exception: + return _unexpected("renaming the accessory") + + return json.dumps({"ok": True}) + def close(self) -> None: """Close the client, and say so rather than raising if it will not go quietly.""" if self._client is None: @@ -370,6 +597,33 @@ def close(self) -> None: self._candidates = {} +def _refuseToRenameADevice(plistXml: str) -> str | None: + """ + Say no if this is one of the owner's own devices, or None to carry on. + + The test is :func:`opentagviewer_export.hardware.is_own_device` - two signals, an Apple model + identifier or the shared secret only a device carries - and it is called rather than + reproduced. The exporter asks the same question to decide that handing over a Mac's keys is a + different act from handing over an AirTag's, and one of those answers going stale while the + other did not is exactly the bug this arrangement avoids. + """ + try: + record = plistlib.loads(plistXml.encode("utf-8")) + except Exception: + return _unexpected("reading the accessory's stored record") + + from opentagviewer_export.hardware import is_own_device + + if not is_own_device(record): + return None + + return _failure( + REASON_NOT_AN_ACCESSORY, + "This is one of your own devices rather than an accessory. Its name is not something" + " this record decides, so changing it here would not change it anywhere you would" + " see it.") + + def _plist(mapping: Any) -> str | None: """ One plist mapping as the XML the importer reads, or None where there is no record. diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index d01bd240..00c111e1 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -812,6 +812,19 @@ def _filterReportsByTimeRange(reports, startMs, endMs): # is enough round trips to be worth avoiding. _ALIGNMENT_PROBE_THRESHOLD_INDICES = 2000 +# How wide a fruitless key search has to be before the accessory is called dead. +# +# **Width, not "we found nothing".** A tag with no key alignment record always searches from its +# pairing date, so a *young* one searches a small range and finding nothing there means very +# little - it may simply not have been near an iPhone this week, and it will report eventually. +# A search this wide means the tag has been silent for months: at an AirTag's ~96 indices a day, +# 20,000 is around seven months. Nothing that has said nothing for seven months is about to. +# +# The point of noticing is not tidiness. Each of these costs a full-history search at ~290 keys +# per request, every time anything refreshes - the account-flagging risk in rule 6, spent on a +# tag that will never repay it. +_DEAD_TAG_WIDTH_INDICES = 20000 + # Apple rejects requests carrying much more than ~290 hashed keys, so a ranged fetch is split # into chunks below that with a little headroom. _MAX_KEYS_PER_REQUEST = 255 @@ -1210,6 +1223,10 @@ def getLastReports( start_dt = datetime.fromtimestamp(start_ms / 1000, tz=timezone.utc) now_dt = datetime.fromtimestamp(now_ms / 1000, tz=timezone.utc) + # 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) + # Per-accessory isolation. One beacon failing used to abort the whole call, # which meant no beacon's updated alignment was persisted - so every later # fetch started from the same wide range again and never converged. @@ -1224,6 +1241,19 @@ def getLastReports( print(f"Got {len(reports)} raw reports for {beaconId}") + # 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". + width_after = _isAlignmentWide(airtag, start_dt, now_dt) + exhausted = ( + not reports + and width_before > _DEAD_TAG_WIDTH_INDICES + and width_after >= width_before + ) + if exhausted: + print(f"{beaconId} has nothing anywhere in {width_before} indices of history;" + f" reporting it as one that appears to have stopped broadcasting.") + if fetched.bounded_to_window: filtered = _filterReportsByTimeRange(reports, start_ms, now_ms) print(f" -> {len(filtered)} reports after filtering to last {hoursBack}h") @@ -1241,6 +1271,19 @@ def getLastReports( # Always written, even when there were no reports: the alignment may still # have moved, and persisting it is what stops the next fetch re-searching. "updatedAccessoryJson": json.dumps(airtag.to_json()), + # **Both of these have to be here, not only on the ranged variant.** + # + # This is the function the app actually calls; `getReports` has no caller in + # Java at all. Java reads these two keys to decide whether a tag is going + # quiet, and a missing key reads as False - so emitting them from the ranged + # variant alone silently disabled the whole backoff, with every empty answer + # counting as a healthy one. See PythonAppleService#toFetchResult. + "exhaustedWideSearch": exhausted, + # Whether this search was an expensive one at all. An accessory with a narrow + # key window costs a request or two, and an empty answer from one means only + # "nothing new in the window asked for" - the ordinary state of a tag that + # reported an hour ago and has not moved. + "wideSearch": width_before > _ALIGNMENT_PROBE_THRESHOLD_INDICES, } return _resultOrError(res, failures, num_items) @@ -1281,6 +1324,10 @@ def getReports( start_dt = datetime.fromtimestamp(unixStartMs / 1000, tz=timezone.utc) end_dt = datetime.fromtimestamp(unixEndMs / 1000, tz=timezone.utc) + # Measured before and after, because "found nothing" on its own says nothing. + # See _DEAD_TAG_WIDTH_INDICES. + width_before = _isAlignmentWide(airtag, start_dt, end_dt) + try: reports = _fetchReportsInRange(account, airtag, start_dt, end_dt) or [] except Exception: @@ -1290,6 +1337,19 @@ def getReports( continue print(f"Got {len(reports)} raw reports for {beaconId}") + # 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". + width_after = _isAlignmentWide(airtag, start_dt, end_dt) + exhausted = ( + not reports + and width_before > _DEAD_TAG_WIDTH_INDICES + and width_after >= width_before + ) + if exhausted: + print(f"{beaconId} has nothing anywhere in {width_before} indices of history;" + f" reporting it as one that appears to have stopped broadcasting.") + filtered = _filterReportsByTimeRange(reports, unixStartMs, unixEndMs) print(f" -> {len(filtered)} reports after filtering to requested range") @@ -1298,6 +1358,16 @@ def getReports( res[beaconId] = { "reports": _serializeReports(filtered), "updatedAccessoryJson": updated_accessory_json, + # Java decides what to do about it; this only reports what was searched. + "exhaustedWideSearch": exhausted, + # **Whether this search was an expensive one at all.** + # + # An accessory with a narrow key window costs a request or two, and an empty + # answer from one means only "nothing new in the window asked for" - which is + # the ordinary state of a tag that reported an hour ago and has not moved. Java + # must not count that against it, or a tag updating happily every day slowly + # accrues strikes and starts being asked less often for no reason. + "wideSearch": width_before > _ALIGNMENT_PROBE_THRESHOLD_INDICES, } return _resultOrError(res, failures, num_items) @@ -1348,3 +1418,25 @@ def whereToLookUpHardware(plistXml: str) -> str | None: except Exception: print(f"whereToLookUpHardware failed, carrying on without it: {traceback.format_exc()}") return None + + +def isOwnDeviceHardware(plistXml: str) -> str | None: + """ + Whether this record is one of the owner's own devices rather than an accessory. + + **What decides whether a rename writes to iCloud or stays a local nickname.** See + `opentagviewer_export.hardware.is_own_device`, and `icloud_bridge.ICloudSession.rename`, which + asks the same question again before writing - this one shapes the screen, that one is the + guard. + + Returns `"True"` or `"False"` as a string, and **None when it could not be established**, + which is not the same as False. A record that will not parse must leave the caller cautious + rather than confidently writing to somebody's account. + """ + try: + from opentagviewer_export.hardware import is_own_device + + return str(is_own_device(util_files.read_data_plist(plistXml.encode("utf-8")))) + except Exception: + print(f"isOwnDeviceHardware failed, carrying on without it: {traceback.format_exc()}") + return None diff --git a/app/src/main/res/drawable/findmy_accessory.xml b/app/src/main/res/drawable/findmy_accessory.xml new file mode 100644 index 00000000..7332a391 --- /dev/null +++ b/app/src/main/res/drawable/findmy_accessory.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/laptop_24px.xml b/app/src/main/res/drawable/laptop_24px.xml new file mode 100644 index 00000000..6cf09545 --- /dev/null +++ b/app/src/main/res/drawable/laptop_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/link_off_24px.xml b/app/src/main/res/drawable/link_off_24px.xml new file mode 100644 index 00000000..b7124ae3 --- /dev/null +++ b/app/src/main/res/drawable/link_off_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/tablet_24px.xml b/app/src/main/res/drawable/tablet_24px.xml new file mode 100644 index 00000000..eb8f1e2f --- /dev/null +++ b/app/src/main/res/drawable/tablet_24px.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/tag_third_party.xml b/app/src/main/res/drawable/tag_third_party.xml deleted file mode 100644 index f040b522..00000000 --- a/app/src/main/res/drawable/tag_third_party.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_device_info.xml b/app/src/main/res/layout/activity_device_info.xml index 4a58bf84..c589cd2d 100644 --- a/app/src/main/res/layout/activity_device_info.xml +++ b/app/src/main/res/layout/activity_device_info.xml @@ -45,6 +45,22 @@ name="importedAt" type="String" /> + + + + + + + + @@ -150,6 +166,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +