Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
1280fa5
Give Java a way to drive the iCloud flow, and one lock to do it under
parawanderer Aug 19, 2026
a9afcb5
Put screens on the iCloud flow, against the interface rather than Apple
parawanderer Aug 20, 2026
b44c84b
Give a beacon a way to say it came from the account
parawanderer Aug 20, 2026
166e135
Actually bring the account's tags in, and keep them in step with it
parawanderer Aug 20, 2026
edb1717
Generate the escrow passcode as a secret, not as a code
parawanderer Aug 20, 2026
bf0d717
Join the account's keychain, and read as a member afterwards
parawanderer Aug 20, 2026
9e0a415
Make the iCloud screens something somebody would want to read
parawanderer Aug 20, 2026
d52458b
Move between the iCloud steps instead of cutting between them
parawanderer Aug 20, 2026
afdf47f
Say what the wait is for, instead of borrowing the next screen's heading
parawanderer Aug 20, 2026
def24e1
Say that an account tag is removed in Find My, not here
parawanderer Aug 20, 2026
eb21c6c
Photograph the tag page, from the window each thing is actually in
parawanderer Aug 20, 2026
a87fbb4
Say what the hex on the Type row is, and offer to settle it
parawanderer Aug 20, 2026
b57cbb3
Give My Devices a way in that survives the first import
parawanderer Aug 20, 2026
5ce306c
Let an accessory be renamed in iCloud, and refuse to rename a device
parawanderer Aug 20, 2026
a15f7e0
Rename an accessory in iCloud; nickname everything else
parawanderer Aug 20, 2026
d79e4ee
Give a third-party Find My tag the Find My mark, in the theme's greys
parawanderer Aug 20, 2026
2acf917
Drive the whole journey, from signed out to tags on the screen
parawanderer Aug 20, 2026
a34df69
Stop losing an opened iCloud session in a null check
parawanderer Aug 20, 2026
bc87c0f
Make the account fake return a record the converter can convert
parawanderer Aug 20, 2026
aee4ec5
Centre the wait instead of tucking it under the heading
parawanderer Aug 20, 2026
08396ab
Tell the map what changed, name the devices, say when it is linked
parawanderer Aug 20, 2026
55109c0
Stop parsing metadata that is deliberately empty, and rename the link…
parawanderer Aug 20, 2026
5fa68e3
Re-read the Apple account on the app's own initiative
parawanderer Aug 20, 2026
67e549e
Geocode each tag as it lands, not after the whole batch
parawanderer Aug 20, 2026
ef5de46
Cap any single instrumented test, so one hang cannot eat the suite
parawanderer Aug 20, 2026
06db8d2
Stop searching for tags that have gone silent, and say so
parawanderer Aug 20, 2026
67ef4d1
Show a silent tag as silent, and let somebody argue with it
parawanderer Aug 20, 2026
a662a88
Draw each tag as it lands, and stop penalising tags that answer
parawanderer Aug 21, 2026
623e833
Say what the report's raw numbers mean, and stop where the sources do
parawanderer Aug 21, 2026
7a446a2
Decode the status byte where Apple defines it, and refuse where it do…
parawanderer Aug 21, 2026
c08217e
Stop an account read deleting the tags it is refreshing
parawanderer Aug 21, 2026
47ec674
Let the local test venv install what the exporter actually needs
parawanderer Aug 21, 2026
402fa95
Emit the backoff signals from the function the app actually calls
parawanderer Aug 21, 2026
6ea6150
Let somebody unlink the Apple account again
parawanderer Aug 21, 2026
f1dc28c
Let a local install build only the ABI it will actually run
parawanderer Aug 21, 2026
2758b6d
Drive the whole iCloud flow across the real bridge
parawanderer Aug 21, 2026
19b9af6
Look a week back the first time, and notice locations found while away
parawanderer Aug 21, 2026
2a896ba
Fill map pins with the theme's colour, and put the emoji in the middle
parawanderer Aug 21, 2026
d8b1a46
Lighten the Find My disc, and make the test that guards it look at th…
parawanderer Aug 21, 2026
00542a8
Give a silent tag a second chance before setting it aside
parawanderer Aug 21, 2026
9f764fa
Start MapsActivity in a test, and say what that still cannot reach
parawanderer Aug 21, 2026
51935fc
Make a session restore in a test, and assert what the map draws
parawanderer Aug 21, 2026
0794810
Write down the two test doubles, which were discoverable by accident …
parawanderer Aug 21, 2026
bebd0f1
Drive the fetch through the bridge, and fix the fake that would have …
parawanderer Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
81 changes: 80 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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/<variant>/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

Expand All @@ -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
Expand Down
70 changes: 68 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>? = 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
Expand All @@ -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 {
Expand Down Expand Up @@ -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")

Expand Down
Loading
Loading