Skip to content

Read tags from an Apple account, and stop the database deleting them - #125

Merged
parawanderer merged 44 commits into
mainfrom
feat/icloud-screens
Aug 21, 2026
Merged

Read tags from an Apple account, and stop the database deleting them#125
parawanderer merged 44 commits into
mainfrom
feat/icloud-screens

Conversation

@parawanderer

@parawanderer parawanderer commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Reads tags straight from an Apple account, so a Mac is only needed for the tags an account cannot give you.

Sign in, unlock the keychain with a device passcode, join it as a peer, and the account's accessories arrive with their names, emoji and key alignment records. Everything after that is the same app as before.

Along the way this fixes several bugs that are not iCloud-specific, including one that is live in 1.0.5 — see below.


The account route

  • Sign-in offers it directly; Settings and My Devices both reach it too
  • Escrow records are listed, unlocked with the Apple device passcode, and joined as a peer that persists — so a later read costs one tap and no passcode
  • Unlinking, which was previously a one-way door. The dialog says what it does not do: the app stays in your Apple device list until you remove it in Find My, and linking again needs the passcode
  • Renaming an iCloud accessory writes the name and emoji to the account; everything else gets a local nickname with the original still shown
  • Removing an account tag is refused, pointing at Find My — this app cannot leave the trust circle and shouldn't pretend to
  • The account is re-read on the app's own initiative every six hours, so a tag added in Find My turns up without anyone pressing anything

Bugs fixed

INSERT OR REPLACE was deleting the rows it was meant to update. SQLite's REPLACE is a delete plus an insert, and Room enables foreign keys, so it fired ON DELETE CASCADE into all three children of OwnedBeacons. Every six-hourly account read was erasing, for every account tag: the user's custom name and emoji, the tag's entire location history, the daily-fetch record, and the silent-tag backoff state. Apple keeps about seven days, so older history existed only there and allowBackup is false.

addNewImport has the identical defect, and it is in 1.0.5 today. Re-importing an export — which the wiki advises, because a newer export carries a key alignment record — cost the same four things. That is the one users of the released app are exposed to.

The silent-tag backoff never ran. Java reads wideSearch and exhaustedWideSearch; only getReports wrote them, and the app calls getLastReports. A missing key reads as false, so every empty answer counted as a healthy scan. Nothing errored — the feature just did nothing.

A first fetch only looked back 24 hours, so a tag last seen on Tuesday showed "No last location known" while its locations sat on Apple's servers, findable by opening history and paging back. A tag that has never been scanned now gets the full week.

My Devices never re-read its locations. Loaded once in onCreate, refreshed only on a removal or rename — so locations found through the history screen stayed invisible until something else recreated the activity. A correct database and a wrong screen.

Map pins ignored the theme. They were filled from a colour resource while the tag card tinted itself from the theme attribute. Identical in every built-in theme, and different the moment system colours are on — so the cards took the wallpaper's tint and the pins did not. Also: the emoji on a pin was off-centre, and a pin could fail to draw entirely where the GPU blur path is unavailable.

Testing

491 instrumented, 199 Chaquopy bridge, 327 exporter — green in about 3m 25s.

The one worth calling out is TheWholeICloudFlowAcrossTheBridgeTest. Every other test of this flow replaced ICloudService with a Java fake, so PythonICloudService — the JSON it builds, the objects it converts, the reason strings it maps — had never run outside somebody's hands. Two bugs shipped through that gap. It now installs a double over the two functions in exporter.icloud that talk to Apple and drives everything above them for real; reinstating either bug turns it red.

Every behavioural change here was verified failable by breaking the code on purpose and watching the right tests go red.

Known gaps, stated rather than skipped

  • Nothing has been tested against a real Apple account — that needs credentials CI does not have. The manual runs are @parawanderer's.
  • MapsActivity now launches in a test and its markers are asserted - a Python-side auth double makes the stored session restore, which is what the drawing hangs off. Still uncovered there: that a redraw replaces a tag's marker rather than stacking a second on top. Driving that needs a production method made visible, which buys coverage by making the thing under test worse.
  • The app cannot yet write bundles; export is still the desktop wizard's job.
  • The status byte on a location report is shown as decimal, hex and binary with no bits named. Apple's spec defines it, AirTags do not follow it, and reading it properly needs a routing table by accessory type that nobody has built.

🤖 Generated with Claude Code

parawanderer and others added 30 commits August 20, 2026 10:44
The service layer the §7 screens sit on: open a client, list what the keychain can be recovered
from, unlock with a device passcode, fetch, take records. An interface, because 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, so those are exactly
the paths that would otherwise never be covered.

**The lock moved, and that is the part with a bug in it if it goes wrong.** `PythonAppleService`
kept a private `ReentrantLock` because FindMy.py drives one asyncio event loop and two threads in
`run_until_complete` on it fails permanently, then keeps failing. The iCloud flow drives the
*same* loop - deliberately, since a second account would be a second device to Apple - so a
keychain unlock collides with the 60-second location refresh exactly as two fetches would, and a
second private lock in a second class would have been no lock at all.

So it is `PythonLock` now, shared, and it takes the work rather than handing out lock/unlock.
That is not tidiness: the iCloud flow is several calls with a person answering a dialog between
them, and holding the lock across one of those waits would freeze every location update in the
app until they got round to typing. Each step takes it, finishes, gives it back; the waiting
happens in Java with nothing held.

Failures cross as `ICloudException` carrying an `ICloudFailure` enum rather than a message, so
the wording stays in strings.xml. `fromWire` falls back to UNKNOWN rather than throwing - a
reason added in Python later must not crash the screen that reports failures - and that
tolerance is exactly why `ICloudFailureWireTest` walks the Python module rather than a
hand-written list: the drift it catches is silent by design. Deleting one case turns two of its
three tests red, which was checked.

248 instrumented tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six steps over one screen - open, choose whose passcode you have, unlock, and an overview of
what is on the account - plus the two failure screens the bridge already distinguishes. The
whole thing talks to `ICloudService` and nothing else, so every state it has to handle can be
produced in a test.

**That is not a convenience.** Reaching these screens for real needs an Apple account in
conditions 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, and they are exactly the states that decide whether somebody comes back
tomorrow or gives up.

The failure branching is the point. "Nothing on this account can be recovered from" is final and
the answer is the import path; "nothing was reported usable at all" is very likely a bad day at
Apple and is worth returning for. Collapsing them tells somebody with a perfectly good account
that they permanently own no tags. Anything unrecognised lands on "try again later" with what it
said - the safe half, since a guess about a cause nobody established is worse than a vague
truth.

Back is a step, not an exit: from the passcode step with more than one device it returns to the
list, so picking the wrong one out of two is not an expensive mistake; with one device it leaves,
because a list of one button is a dead end that reads as the button not working; and mid-call it
does nothing, so an unlock already talking to Apple is not stranded.

The empty device list gains a second button - fetch from the account, and import from a file -
which is §7's "two buttons, not four". The copy under it said a zip was the only way in and now
does not.

Three things @parawanderer caught looking at it that no test had an opinion about: the results
heading was `icloud_found_x_tags`, a format string, so it rendered a literal `%1$d`; there was an
"Import Devices" button on what should be an overview, implying a choice that does not exist,
since everything on the account comes in; and the cards were grey rather than the surface the
Apple account block in Settings uses.

Tests in three classes, because they answer different questions: the flow, back presses, and
what the user is told when it does not work. 295 instrumented tests pass. Two Espresso traps on
the way, both now written down: `pressBack()` throws "Pressed back and killed the app" when
leaving is the expected outcome, and `onActivity` cannot run on a destroyed activity - so
leaving is asserted from the scenario's lifecycle state instead.

Nothing is written to the database yet. That is the next commit, and per @parawanderer the model
is a cache of the account rather than an import: rows so history and offline viewing work, with
the list refreshed from the account each time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Schema v4: `from_account` on `OwnedBeacons`, defaulting to 0.

**The default is the whole point, and getting it wrong would delete people's tags.** An account
beacon is a cache - the list is re-read from Apple, so one that has left the account is removed
here too. A file-imported beacon is the only copy in existence: nobody else holds it and the
export it came from may be long gone. Without a way to tell the two apart, "drop what is no
longer on the account" deletes every imported tag the first time somebody fetches; with the
default the wrong way round, it deletes them all on upgrade. Every row that predates this came
from a file, so 0 is not merely a safe default, it is the true one.

Pure additive ALTER, so nothing moves. Tests cover v3 to v4, an empty database, and the direct
v1 to v4 path - users skip releases, so the sequential path being right is not enough. Flipping
the default to 1 turns exactly the two guarding assertions red, which was checked.

Schema 4.json exported and committed, per rule 1.

298 instrumented tests pass. Nothing sets the column yet - the refresh that does is next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The piece between the screen and the database. Everything on the account is taken - importing
from iCloud is all of it, and choosing a subset is what exporting is for, which lives on the
device list behind a long press. The overview that follows is an overview of what arrived, not
a thing to act on.

`refreshAccountBeacons` writes what the account holds and retires what has left it, because
these rows are a cache of somebody's account rather than an import of a file.

**Every write is scoped to `from_account = 1`, and that scope is the whole safety story.** A
file-imported beacon is the only copy in existence - the export it came from may be long gone
and `allowBackup` is false - so a refresh must never reach one. Removing the scope turns exactly
the two tests guarding that red, including the one where an account holding nothing wipes a zip
import. In production that would be somebody's tags gone, from an ordinary action, with no
export to redo them from.

Two smaller things that are easy to get wrong and are covered:

- Rows are written with `is_removed = 0`. Retiring rather than deleting is deliberate, so a tag
  leaving the account does not take its location history with it - but re-inserting one that
  came back has to clear the flag, or it returns present in the table and absent from every
  screen.
- An empty account uses a different query, because `NOT IN ()` is not valid SQL.

The version recorded is `account` rather than a format number: a bundle's version tells a reader
how to interpret the files in it, and there is no file here.

The device list rebuilds itself when something arrives, rather than appending - it accumulates
into its list on load, so a second fetch would show everything twice.

306 instrumented tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The passcode this app's own escrow record will be enrolled under, ready for the join.

**Nobody ever sees it**, which removes every constraint a human-facing code has. The first
version reused Crockford base32 from `BundlePasscode` - an alphabet that exists to drop I, L, O
and U so somebody reading a code off one screen does not mistype it into another. That is a real
constraint there and an irrelevant one here, paid for in entropy per character. Sharing it would
also have tied this to a decision made for legibility: narrowing that alphabet further, for
better reasons, would have quietly weakened this.

So the strength is stated in bytes - 32 of them, 256 bits, from `SecureRandom` - and the encoding
is incidental. base64url only because the protocol takes a string and that survives a property
list and an SRP exchange without escaping, unpadded and unwrapped so it is one token with nothing
in it that anything downstream might treat as a delimiter.

Not numeric, deliberately: enrolment publishes `SecureBackupUsesNumericPassphrase` by asking
whether every character is a digit, and an all-numeric passcode advertises itself as the sort of
thing a six-digit PIN protects.

There is no upper bound to respect. Enrolment refuses an empty passcode, on the grounds that a
record enrolled under one "could be recovered by anyone", and nothing above that is rationed.

Tested for the failures that look fine in a log: a generator that repeats itself, one that
returns something shorter than intended, and a stored value that came back truncated.

Nothing calls this yet - the join it belongs to is waiting on a way to resume from a persisted
identity, which FindMy.py does not expose today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FindMy.py added `resume` and `JoinedPeer` at 337381d, in answer to the request in
tmp/findmypy-request-resume-joined-peer.md. Pin bumped in all four places the repository names
it - the app build, the bridge tests, and the exporter's pyproject and lockfile.

**The answer corrected the reason for wanting this, and the correction is worth recording.** I
had it that reading borrowed the sponsoring device's identity, so removing that device would
break us. It does not: `unlock` yields the keychain's *view* keys, which every member holds a
share of, and those keep working. The real argument is the next step - when the view keys roll,
which is expected whenever the circle's membership changes, only a current member is given
shares of the new ones. A non-member goes quietly stale: still holding keys, still looking fine,
decrypting nothing new. In this app that is a map that stopped updating for no reason, which is
the failure shape of #43 and #119 and the one nobody can diagnose for themselves.

So: `join` writes once, and `resume` reads as that member on every later run with no passcode.
`unlock` now recovers explicitly rather than through `client.unlock`, which does the same two
steps and keeps the peer to itself - the peer is what sponsors a join, so it has to survive.

Three things the answer also settled:

- **The entropy does not resume anything**, and is kept anyway. The membership is how a refresh
  avoids a passcode; the entropy with the record's passcode is how the peer is recovered through
  escrow if the encrypted store is destroyed. Neither substitutes for the other.
- The device description is **read** from the account's own identity, not composed - rule 11,
  and this is the case that rule anticipated: the model and build reach the escrow record's
  metadata and the serial reaches the peer's stable info, both of which a person reads.
- A failed join is never retried. A response that will not decode is not a call that failed.

The module docstring no longer claims nothing here writes, because that stopped being true.

`ICloudFailureWireTest` earned itself: it failed the moment two Python reasons had no Java case,
which is the silent drift it exists to catch.

312 instrumented tests and 184 Python tests pass. **Nothing has joined a real account** - the
call is written and unexercised, and FindMy.py notes that no live account has read shares as a
joined peer either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@parawanderer looked at the rendered screens and most of this is his. The screens were cramped,
the button moved between steps, the device list was a wall of comma-separated identifiers, and
one screen said something that was not true.

**Devices are tiles now.** An icon chosen from the record's own `device_model_class` - phone,
tablet, laptop, or the generic one for a Watch or whatever Apple ships next - then the name, then
the identifiers small and quiet underneath. Somebody choosing here is picking between physical
objects they own, and a phone that looks like a phone is most of that job. The passcode step
reuses the same tile rather than printing a sentence, because recognising the device is the whole
task at the moment you are about to type its passcode.

Which meant answering a question worth writing down: **`device_name` is the user's own and is
frequently empty.** FindMy.py falls back to the literal "unnamed device"; the tile falls back to
the class, so an iPad nobody renamed reads "iPad" rather than a placeholder.

**One button, pinned to the bottom.** It was four, each at the bottom of however much content its
step happened to have, so it moved up and down the screen as the flow went on. Pinning it made
the list above it able to overflow, so `FetchFromICloudScrollTest` drives 25 devices and checks
the list reaches its end, the bar does not move with it, and the bar is on screen before any
scrolling. It also fixed a label: the passcode step's button said "Unlock your Apple keychain"
directly under the heading "Unlock your Apple keychain".

**Back is in the footer**, next to the button, and only where there is a step to go back to. The
system back already did this, but system back is not an affordance - nothing said the device list
was still there, so picking the wrong device out of two looked final.

**The tiles have touch feedback again.** Giving them a tinted background silently overrode the
ripple the old `<Button>` had for free; `ripple_rounded_rect` already existed for exactly this
and its 20dp mask matches the tile.

**And the copy said something false.** "N other items were your own devices rather than tags"
described the skipped list wrongly: records are skipped for having no private key, not for being
devices. Devices *with* keys are candidates - the desktop CLI filters those out only for its
scripted path, behind `--include-my-devices`, and this app never applied that filter, so an
iPad already comes in. It now says they carry no location key, which is the true reason and the
one nothing can change.

The no-tags screen links to the export wiki page, read from `app.properties` like the device
list's. That screen is read by the person who cannot make a bundle themselves, so a link they can
forward is the difference between advice and a next step.

Dates spell the month: "added Mar 12, 2024". The short format is numeric, and 3/12/24 is March in
one country and December in another on a screen about recognising your own hardware.

326 instrumented tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They snapped: `showOnly` set one container visible and the rest gone. The sign-in flow beside
them slides and fades, so the app changed character halfway through a single errand.

Reuses `StepTransition` rather than growing a second one, which also inherits the thinking
already in it - all three parts of which matter here:

- **The outgoing step is hidden at once, not faded.** These containers are siblings in a vertical
  LinearLayout, so one still fading still occupies its height and the arriving step is laid out
  underneath it, then jumps when the old one finally goes. It reads as a bug rather than a slow
  fade. Cross-fading properly needs a FrameLayout, and nobody wants that badly enough yet.
- **Both views are in their final state before the call returns.** All 326 tests passed without
  being touched, which is the whole point of that rule: nothing has to wait for an animation to
  know what the screen says.
- **`Direction.NONE` is not "no preference"** - it means being drawn rather than navigated. The
  spinner uses it, and so does re-showing the passcode step after a rejection, because you did
  not go anywhere. Animating those would look like the app replaying steps nobody took.

Forward going deeper, back returning to the device list - the same journey the footer's back
button and the system back both take.

`StepTransition` now lives in a package called `ui.login` and is used by a screen that is not
login. Worth moving eventually; not worth the churn mid-branch.

326 instrumented tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every wait showed "Unlock your Apple keychain" with a spinner under it - the device step's own
heading - so it read as that page failing to populate rather than as work happening. @parawanderer
saw it in a walkthrough and said so.

Each wait now says what it is waiting for: looking for a device, unlocking, bringing tags in. A
long wait tells you which part is slow rather than just that something is.

The spinner is the same `CircularProgressIndicator` the sign-in screen uses, and is now
configured the same - track, rounded ends, inward show and hide. Bare it is a stroke with square
ends and no ring behind it, which at that size reads as a stray refresh glyph.

**What is asserted about it, and what is not.** Drawing the view to a bitmap twice a quarter of a
second apart gave identical pixels - and still did with the animator duration scale forced to 1
by reflection, the way StepTransitionTest does it. That is the headless managed device rather
than the app: a view drawn by hand off-screen does not tick its animator, the same trap as the
MaterialButton icon a few commits ago. So the pixel comparison is gone rather than kept as a test
of this harness, and what remains asserts what is true and checkable - it is indeterminate, and
it is on screen while the account is read. The attempt is written into the test so nobody repeats
it; whether it visibly turns is a question for a device with a window.

`FakeICloudService.takingItsTime` exists so the waiting screen can be reached at all. Everything
else in it answers instantly, which is right for a test and useless for looking at the screen
somebody actually spends time on.

328 instrumented tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing a tag read from the Apple account looked like it worked and then
undid itself: the row is a cache of the account, so the next refresh wrote
it back with is_removed = 0 and the tag returned with nothing logged and
nothing said. So the destructive button is not offered for one, and what
replaces it names the place it can actually be removed.

A mixed selection removes what the app owns and says what it did not,
rather than refusing the lot for the sake of one wrong tap.

Two things this turned up on the way:

Tapping an account tag crashed the device info screen outright.
getImportById takes a primitive, and importId is null for every row the
account refresh writes, so it unboxed a null in onCreate. That screen's
"Exported by", "Exported at" and "Imported at" describe a bundle an account
tag does not have, so they give way to the one fact that is true of it.
The two conditions are kept apart deliberately - a file-imported row whose
import record had gone would otherwise be labelled as coming from the Apple
account on the strength of a missing join.

And the iCloud tests were importing their fake tags into the real database
and leaving them there. On the managed device that is one run; on anyone's
own device it is tags they did not import and cannot explain. It also broke
this change's tests, which passed alone and failed in the suite because the
leftovers had pushed their rows off the bottom of the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The new source row and the explanation dialog had never been looked at.
Adds pictures of both, plus the overflow menu, which answers the question
they were taken for: Remove Device is still listed for an account tag, and
tapping it explains where the tag can be removed.

UiAutomation.takeScreenshot() is the obvious way to do this and is worse
than none: on the headless managed device it returned the same blank bitmap
for every call - four byte-identical files that would have gone on being
identical however the screens changed. A popup menu and an alert dialog
each get their own window, so these draw upward from a view inside the
window being photographed instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
When nothing recognises an accessory, Type reads "vendor 0x0ABC product
0x1234". Those are real Bluetooth SIG registry values and one search
settles what the thing is - but only for a reader who knows the number
means something. where_to_look_up has been implemented in Python and
reachable from Java since the heuristic existed, with no screen calling it.

Python decides whether there is anything to look up; the app writes the
sentence. The Python one is English, composed in a module the desktop
exporter shares, and this ships in ten languages - so the judgement stays
where the vendor table is and the wording goes where translations are.

Both answers now cross in one call rather than two: each starts an
interpreter and parses the same plist, and the second question is only ever
asked about the first one's failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both ways to get tags in lived in the empty state, which is hidden the
moment anything is imported - so a user with one tag had no way to add a
second, from a file or from their account. The map's menu still reached the
file picker; nothing reached the account at all.

That last part undid the point of joining the keychain. The app joins so a
later read costs one tap and no device passcode, and there was no way to
ask for a later read.

Uses the toolbar variant that already has an overflow button, and the same
two strings the empty-state buttons use - they are the same two actions,
and renaming them in a menu would make them look like different ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FindMy.py can save a naming record, and for an AirTag or a Find My-certified
tag that record is the only place its name and emoji live - so writing it is
the whole change, and the owner sees it in Find My on their own devices.

An iPhone, iPad or Mac is a different thing. Its name comes from more places
than this record, so writing one would leave Find My disagreeing with the
device itself. Those get a local nickname instead, and the refusal is made
here rather than trusted to the caller: is_own_device has 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 plist Java already holds, so deciding what kind of
thing this is does not cost a read of the whole account.

Also fixes eleven bridge tests that were already failing on this branch.
FakeRecord never grew the escrow fields the device list started reading, so
the joining tests died on an AttributeError. It now carries every field the
bridge touches, including the empty ones - the phone nobody ever renamed is
the case the tile's fallback exists for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Renaming now does two different things, and which one depends on the tag:

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 the
rename is written to the account and shows up in Find My on the owner's own
devices. One of the owner's own devices takes its name from several places
at once, so writing this record would leave Find My disagreeing with the
device itself; those keep a local nickname and go on showing the real name.
Anything imported from a file was never on this account at all.

Three decisions worth stating, because none of them is visible afterwards:

The stored naming record is edited rather than covered with a nickname. A
nickname would have been far 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.

Null from the heuristic means "not established", not "accessory". Before it
answers, renaming stays local: the cautious mistake changes nothing anybody
else can see, and the other one writes to somebody's Apple account.

A write that cannot reach Apple says so and changes nothing. It does not
silently demote itself to a nickname, which would be the app telling the
user something about their account that is not true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The concentric-arcs icon said "this broadcasts and can be found" without
borrowing anything, which was the right call when there was nothing better.
The Find My mark is what people actually recognise at 24dp.

Blue kept, green themed. The cone and the white-ringed dot are the parts
that make it readable; recolouring those to the user's accent would leave a
themed blob that no longer says anything. The green surround carries no
meaning at all, so it follows colorOutline and sits on the row rather than
on top of it.

Three things this needed that are invisible afterwards:

BeaconIcon now owns the tint as well as the resource. All three surfaces
tinted their icon to colorOutline, and that flattens every path in this
vector into one colour - a featureless grey blob, which is a perfectly
good-looking icon of nothing. Both branches always set the tint, because
the device list is a RecyclerView: clearing it without setting it back
would leave whichever rows were recycled painting an Apple logo untinted,
which reads as a scrolling bug.

The tonal steps are one grey with translucent overlays rather than three M3
grey roles. That is how the original is built - the two inner greens are
tints of the outer one - and mapping them to unrelated roles inverted the
ordering, lost the softness and very nearly took the white ring with it.

And it is scaled to fill its box. The artwork covers two thirds of its
viewport where apple.xml covers about ninety per cent of its own, so beside
each other they read as one icon at two sizes.

tag_third_party.xml and the unthemed apple_findmy.xml go with it; nothing
referenced either any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 produces them: it signs in, opens the device list,
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, because they diverge on the answer that is out of the user's
hands. An account with tags ends with a list of them; an account with none
ends by offering the only thing that could still work. Nobody exercises the
second by hand, because producing it means owning an Apple account with
nothing in Find My.

One of them is asserted backwards from how it was written. Taking the file
offer finishes the device list, because the picker lives on the map and the
request is handed back rather than duplicated - so with the map stubbed the
stack simply empties. The first version expected the list to still be there
and failed with NoActivityResumedException, which reads like a crash and is
the app doing exactly the right thing.

The map is stubbed and that is a real gap, not a convenience: it needs Play
Services the aosp-atd image has not got. A fake IMapProvider is what would
close it.

Paced with TestPace, so this is the pair to run with slowMotion when
somebody wants to watch the app work rather than read that it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The real iCloud flow was dead on every device. openFor guarded with
`made == null || made.toJava(Object.class) == null`, which reads as careful
null handling and is a guaranteed failure on the path where a session was
actually created: Chaquopy cannot convert an arbitrary Python object to
java.lang.Object and throws ClassCastException. Chaquopy already hands a
Python None back as a Java null, so `made == null` was always the whole
check.

The screen then reported it as "no signed-in account" - a cause it had
invented, since null there has two meanings and whatever failed had already
logged which. It now says it does not know. From the outside all of this
looked like a button that did nothing.

Nothing could have caught it. Every screen test replaces ICloudService with
a Java fake, which is right for testing screens and means this class had
never run outside somebody's hands. Everything external here lives behind
Python, so a fake on the Java side of the bridge skips the bridge - which
is exactly where the bug was.

The first regression test written for this was a decoration: it pinned both
facts the failure hinged on and still passed with the bug reinstated,
because a null account is refused before the conversion is reached. Only
the success path throws. The test now assembles an account the bridge
accepts - a SimpleNamespace carrying the two attributes openSession looks
for - and drives openFor across the real bridge, with no Apple account and
no network. That one goes red with the bug in and green with it out.

Also gives the Anisette status spinner the configuration every other
spinner in the app has, and a test that walks every layout to check the
rest, so a bare arc cannot creep back in. (Under Remove animations they all
render as Material's static refresh glyph - that one is by design.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Python error in the logs was not a product bug and not the user's: it
came from a test run, where FakeICloudService returned the string
"<plist/>" as an accessory's record. The app hands whatever comes back to
convertPlistToJson, and an empty document fails there with 'NoneType'
object is not subscriptable.

That failure is swallowed on purpose - a tag with no accessory JSON is
backfilled on the first fetch - which is exactly why this mattered and why
nobody noticed. Every test that "imported" a tag from the account was
writing rows with no accessory state at all, so a tag that imported and
could never be located looked identical to one that worked, and nothing
downstream of the conversion was exercised by anything.

The fake now returns a record shaped like a real one, using the committed
fixture's key material - a 28-byte master key and two 32-byte shared
secrets, which is what FindMyAccessory.from_plist reaches for. And the
journey test asserts what actually matters: that every row written for the
account carries accessory state, not merely that a row appeared.

It also returns a naming record now, so imported tags have names on screen
instead of falling back to their model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other step on this screen is content that starts below the title and
grows downward. The wait is a spinner and a line of text, and wrapped to its
content it sat just under the heading with the rest of the screen empty
beneath it - 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
loose on purpose: what is pinned is that it is not hugging the heading.

Covers all three waits - looking for a device, unlocking, bringing tags in -
because they are one container with the caption swapped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things @parawanderer found by importing a real account.

**The map stayed empty after importing.** It reads its beacons once and
re-reads only when the device list says something changed - and the flag
that says so was destroyed by the thing that set it. Finishing an account
read sets it and then calls recreate() to rebuild the list, which builds a
new activity where a plain field is false again. The tags were in the
database, they were on the device list, and the map went on showing nothing
until the app was restarted. It survives in the instance state now, and the
test does the whole sequence - import, rebuild, leave - because any test
that set the flag and left without the rebuild in between would have passed.

**Every iPad and Mac on the account was drawn as a generic tag.** The icon
was chosen by vendor id, which does not identify these: an AirTag's record
carries 76 and an iPad's carries -1, so they all fell through to the
third-party branch. An account read is mostly devices, so the list came back
as a column of identical unknown things. The model is the direct evidence -
an accessory leaves it empty, a device fills it with an Apple model
identifier - and the tests pin it in both directions, including that an
accessory's empty model is not mistaken for a device.

**Settings read the same whether or not the account was linked.** Wrong
twice: somebody who has linked cannot tell, and somebody who has not is told
their tags will "update" when nothing has ever been read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… item

Two things from running a real account.

**A logged stack trace per tag, per render.** FindMy.py writes
`cloudKitMetadata` as empty bytes for a record read from the account -
CloudKit's own metadata cannot be reconstructed from a decrypted record, so
the field is a placeholder. The app handed that to Python to parse as a
binary plist, which started an interpreter and produced
plistlib.InvalidFileException every time. Empty is a real answer here, and
it is now recognised without crossing the bridge.

**The overflow item is a linking action, so it says so and goes away.** It
read "Fetch my tags from my Apple account" whether or not the account was
already linked, which describes work already done - after linking, the app
is a member of the keychain and re-reads without asking for anything.

That last change broke the journey test's second-read step, which reached
the account through that menu item. The property it protects - a member
never asks for a passcode again - is not about buttons, so it opens the
screen directly now. Tying it to whichever affordance exists this week is
how a test starts failing for reasons unrelated to what it covers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Joining the keychain 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 - which is the opposite of how a linked
account should behave, and it is why the account screen could be left
sitting on "Bringing your tags in..." for twenty minutes behind a queue.

Six hours, against the location refresh's one minute. The two answer
different questions: locations change constantly and are the point of the
map, while what tags exist changes when somebody edits their account.

Busy is checked before due, and that order is the point. Calls into Python
are serialised and one accessory with no key alignment record can run for
minutes, so a read that went ahead while a fetch was running would not wait
politely - it would take the lock the moment that fetch released it, in
front of whatever the user did next.

A membership the account no longer honours is forgotten rather than
retried. Removing this app's peer is how somebody revokes it; keeping the
dead membership means retrying keys that cannot work on every interval,
forever, with nothing on screen explaining why the tags stopped changing.

One test here was a decoration until it was checked: it failed the read
with whereTheServiceIsUnsure(), which breaks a call this path never makes,
so the read succeeded and it asserted nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RxFlows.allThen runs its `then` after every stream completes, so geocoding
waited for the entire location fetch - and a fetch is one sequential request
per tag, where a tag with no key alignment record takes minutes. Six tags
meant every card showed raw coordinates for twenty minutes, despite
geocoding being a Google call with nothing to wait for.

The comment said "once, after every accessory has landed", which is exactly
what it did and exactly the problem.

Cheap to repeat per accessory: updateBeaconGeocodingsSync already skips a
beacon whose location has not moved since its last geocoding, so the extra
passes cost a map lookup each.

**Not covered by a test.** Everything here is a private method on
MapsActivity, which needs Play Services the aosp-atd image has not got, so
there is nothing that can drive it. The fake IMapProvider already on the
list is what would make this and the journey test's map step reachable -
this is the second thing now waiting on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A test that blocks forever does not fail. It stops the run where it stands
with no name attached and nothing in the report, and the emulator goes on
holding its log files open afterwards - which then breaks the *next* run
with "Cannot access output property 'resultsDir'", a message about nothing
to do with the cause.

That happened here. A test launching the real map screen sat waiting on the
Apple service for ten minutes of a thirteen-minute run, and finding out
which test it was meant reading logcat file timestamps by hand. With this it
fails at two minutes, says its own name, and everything else still runs -
verified by writing a test that sleeps for ten minutes and watching the run
end in 2m 27s with that test red.

Two minutes rather than something tight: the slowest honest tests drive a
whole sign-in, and a demo run adds slowMotion pauses to every step. This is
a backstop against hanging, not a performance budget.

For reference, a healthy full run on this machine is 2m 59s wall clock for
406 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tag with no key alignment record searches from its pairing date at a
request per ~290 keys. So the tags that never answer are the expensive ones:
where a healthy tag costs one request, a silent one costs hundreds - and
costs them again on every refresh. @parawanderer's account had two doing
47,000-index searches on every tick. 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 states, not two, and the middle one is @parawanderer's:

  found something -> everything held against it is cleared
  found nothing   -> the wait before the next attempt lengthens
  found nothing across MONTHS of history -> set aside entirely

The distinction matters because "we searched and found nothing" says almost
nothing on its own: an unaligned tag searches from its pairing date, so a
young one searches a short range and an empty answer means it has not been
near an iPhone this week. Only a search that was already very wide - over
20,000 indices, roughly seven months of an AirTag's silence - and that did
not narrow says the tag has stopped broadcasting. Python is where the width
is known, so Python reports it and Java decides what to do about it.

The backoff is 0, 0, 15m, 1h, 4h, 12h, 24h and then stops growing. The first
two silences buy nothing, because a fortnight in a drawer is a normal tag
having a normal week; the cap exists because a tag can always come back and
a bike found after six months should be noticed within a day, not never.

**A manual refresh is never throttled**, and that is structural rather than
remembered: the filter lives in its own entry point, toScheduledAccessoryRequests,
which only the periodic tick calls. A boolean on the existing method would
have worked and would have been one careless call site away from a refresh
button that silently does nothing - the exact failure this feature exists to
prevent. Reinstating that leak turns amanualRefreshIsNeverThrottledOrSkipped
red, and giving up on any empty search turns anemptySearchBacksOffRatherThanGivingUp
red; both were checked by making them fail on purpose.

Schema v5 carries fruitless_scans, last_scan_at and ignored_at, with defaults
meaning "healthy, never scanned, not ignored" so no upgrade decides somebody's
tags have died. Tested from v1 forward, because users skip releases.

On screen: the device list says "No updates for months - paused checking"
instead of the generic "no last location known" - those look identical and
are not, and only one of them is actionable. The tag page explains it and
offers to look again now, which travels the manual path and so ignores the
backoff by construction. Nothing clears the ignored flag specially: a
successful search does it in the one place every successful search does.

Behind the debug switch, the tag page now also shows the last fetch attempt,
the newest result held, and the backoff state - so "why has this stopped
updating" has an answer rather than a guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The skipping was invisible: underneath, a tag given up on is left out of the
scheduled fetches, and skipping something quietly looks exactly like failing
to look for it. The device list now says why, and the tag page offers the
one action that can change the answer.

The test caught its own mistake before the feature's. The first version
asserted withText(no_last_location_known) against the whole screen, which
passes only while exactly one row happens to say it - so breaking the
feature made it fail with an ambiguous matcher rather than a disagreement,
and it would have stayed green with the two rows' subtitles swapped. It now
finds the row containing the tag's name and asks what that row says, which
is the assertion that was meant all along.

Mutation-checked both ways round: with isIgnored() forced false, exactly the
two silent-tag assertions go red and the healthy-tag one stays green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, and the first is why the other two matter.

**Tags did not update one by one.** addBeaconLocationsToCurrent only
updates the in-memory model; showLastDeviceLocations is what redraws, and
the periodic refresh called it once, in the terminal subscribe. So every
card waited for the slowest tag in the batch - and a tag with no key
alignment record takes minutes, so six tags meant a quarter of an hour of a
screen that had the answers and was not showing them. Both the periodic
refresh and the initial load now redraw per accessory.

**Tags that update every day were accruing fruitless scans.** Spotted by
@parawanderer in the database: healthy tags carrying fruitless_scans of 1.
The rule counted any fetch that returned no reports, but an aligned tag that
reported an hour ago and has not moved legitimately returns nothing for the
next window - that is what a stationary tag looks like, not silence. Those
tags would have drifted into being asked less often for doing nothing wrong.
Python now says whether the search was expensive at all, and only an
expensive empty search counts against a tag; a cheap one is recorded as the
success it is.

**And the scheduled fetch is ordered rather than arbitrary.** The batch is
sequential and often abandoned half way, so whatever sorts last is not
merely late - it is the tag that is always skipped, forever, through no
property of its own. Tags that answered last time go first, because they are
cheap and they are the rows that visibly change; never-scanned sit in the
middle, having earned neither place; silent ones go last. Shuffled within
each group, which is what stops any one tag being permanently last. On a
fresh install everything is in the middle group, so the whole batch is
simply random - the right answer when nothing is known, and it falls out of
the bucketing rather than being a special case.

The shuffle test was measuring java.util.Random rather than this code. It
built new Random(0), new Random(1)... and asserted the last element varied;
Java scrambles small consecutive seeds weakly, and in Fisher-Yates the first
draw picks the last element, so the same tag came last forty times running.
It now reuses one Random across the runs, which is also what the repository
does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The history debug row read `status:144, conf:0, acc:83`, which is true and
tells nobody anything. Annotating those three numbers turned out to be mostly
a question of what may honestly be claimed about them.

The SEEMOO paper (arXiv:2103.02282) does document the report layout, in Fig. 2:
timestamp 4, confidence 1, ephemeral key 57, encrypted location 10, GCM tag 16
- 88 bytes, exactly the length the pinned FindMy.py branches on, at exactly its
offsets. So the confidence byte sits ahead of the ciphertext and outside the
authentication tag: Apple's server can put anything in it and nothing would
detect that.

Accuracy is metres, which the paper itself states as an assumption validated
experimentally rather than as fact. Its Table 6 also measured the byte against
a GPS trace: pessimistic for a stationary tag, and badly optimistic for a
moving one - 145 m claimed where the truth was 581 m. It is a hint, not an
error bar.

The status byte is shown as decimal, hex and binary and no bit is named. The
paper labels it "Status (e.g., battery level)" and defers to Apple's MFi-gated
spec; the bitmasks in circulation contradict each other, and the ordinary value
0x90 comes out as "battery low" and "battery full" at once under the most
repeated one. The class javadoc records the conflict, and the reading that
would resolve it, marked as the inference it is.

nobitIsGivenAMeaning sweeps all 256 values for any such label, so the next
person to add one has to bring a source. Verified by reinstating the bug -
a "(battery full)" suffix on 0x10 and no zero-padding - which turned exactly
three of the ten red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…esn't

@parawanderer got the Find My Network Accessory Specification and Caesar
Creek's write-up open, which turns yesterday's guesswork into a citation. The
spec does define the byte, 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
    Battery state: 0 = Full, 1 = Medium, 2 = Low, 3 = Critically low

So the shape guessed at yesterday was right and the specifics were not: the
constant marker is bit 5, not bit 4. go-haystack's 0x40/0x80/0xC0 are that
enum shifted into place; its 0x10 for "full" should have been 0x20. The
bitmasks a chatbot produces - paired, sound playing, motion detected - are
invention.

The catch is that an AirTag does not follow the table, and this app mostly
sees AirTags. 0x90 has the marker bit clear and reserved bit 4 set, and so
does the 0x10 in Catley's teardown; the specification governs third-party MFi
accessories and AirTag predates it. Decoded anyway, 0x90 reads "battery low"
for a tag whose own record says full.

So the reading is appended only to a byte that actually conforms - marker set,
reserved bits clear - and is worded as a claim rather than a measurement,
because Caesar Creek record that a beacon sets this field itself and that a
device type in those "reserved" bits suppresses unwanted-tracking alerts. It
has a reason to lie.

Every source is linked from the javadoc; all of them were hard to find.

BatteryLevelDescription's disclaimer is narrowed to match: the four state
names and their order are now Apple's own, offset by one for "unknown". The
percentages and the iOS-warning claim remain unverified, and still say so.

Verified by deleting the conformance gate, which turned four of the thirteen
red including the AirTag case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer and others added 11 commits August 21, 2026 09:34
@parawanderer set custom emoji on an iPad, a MacBook and a duplicate iPad,
restarted the app, and the UserBeaconOptions rows were gone. They were, and
so was more than that.

Room's @insert(onConflict = REPLACE) compiles to SQLite's INSERT OR REPLACE,
which is not an update: on a primary key conflict it DELETEs the existing row
and inserts a new one. Room turns foreign keys on, so that delete runs every
ON DELETE CASCADE hanging off OwnedBeacons - and it has three children:
UserBeaconOptions, LocationReport and DailyHistoryFetchRecord.

refreshAccountBeacons re-inserts every account beacon on every read. So each
background read - every six hours, and on every start - was erasing, for every
account tag: the user's custom name and emoji, the tag's entire location
history, the record of which days had been fetched, and the silent-tag backoff
state. None of it announced itself, and nothing the user did was connected to
it in time.

The location history is the worst of the four. Apple keeps about seven days,
so anything older exists only in this database, and allowBackup is false.

addNewImport has the identical defect and its own javadoc says so - "will
update data for existing beacons by beaconid". Re-importing is something people
are told to do, because an export made after format 0.0.2 carries a key
alignment record an older one lacks. It cost them the same four things.

Both now insert what is new and UPDATE what is held. Three columns are written
through COALESCE and each choice matters:

- accessory_json keeps what is already there. It is not a copy of the plist,
  it carries the rolling-key alignment state maintained after every fetch, and
  overwriting it with a freshly converted one sends the next fetch back to
  searching the tag's whole history. That is the account-flagging expense from
  issue #30, and the old code was re-inflicting it four times a day.
- alignment_plist prefers the incoming copy but is not cleared by a read that
  did not carry one.
- content likewise.

Six tests, written before the fix; four failed on the first run. insertAll
keeps its REPLACE for genuinely new rows and now carries the warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`./gradlew testAll` could not pass on a clean checkout. setupTestVenv installs
python/requirements.txt, which had drifted from pyproject.toml: pyzipper and
questionary were missing, so three CLI test modules failed to import and the
encrypted-bundle test failed on a deferred import.

CI never noticed because it installs the exporter with `uv sync --frozen` from
pyproject instead. That is the whole failure mode - a dependency in one file
and not the other is green everywhere that matters to a PR and broken for
anybody running the suite locally - so the file now says that adding an
exporter dependency means editing both.

Also records the conclusion of the status-byte work in LocationReportFields:
an AirTag, an older Apple device and a third-party gadget can each use those
bits differently and all be correct, so reading them properly needs a routing
table by accessory type rather than one decoder. Nobody has built that, and
doing it honestly means sitting down with several tags at several battery
levels. The conformance gate keeps the app quiet until somebody does.

testAllOnDevice: 452 instrumented, 194 bridge, 328 wizard, all green, 4m 6s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The silent-tag backoff has never fired. Java reads `wideSearch` and
`exhaustedWideSearch` off each per-beacon dict, and only `getReports` wrote
them - which has no caller in Java. The app calls `getLastReports`, so both
sets arrived empty every time, every empty answer fell through to "this scan
succeeded", and nothing was ever counted against a tag or set aside.

A missing key reads as False, so there was nothing to notice: no error, no
warning, just a feature that quietly did nothing. a662a88 made it worse by
routing the default case to recordSuccessfulScan, which is right given the
signal and wrong without it.

`getLastReports` now measures the key-index width before and after the fetch
and emits both flags, as the ranged variant already did.

Five tests, verified by deleting the emission again - all five go red. One of
them pins the sharp edge found while writing them: `wideSearch` is derived
from the width of the range searched, not from whether the tag has an
alignment record, and those are the same question only because the app asks
for at most 24 hours. That is ~96 indices against a threshold of 2000. Raising
RefreshPolicy's cap past about 21 days would make every healthy tag's ordinary
refresh look expensive and start it accruing strikes - the bug @parawanderer
found in the database, reintroduced from a file nowhere near this one.

The seam this fell through - Python emits, Java parses, nothing exercises both
- is still uncovered. That is what the Python-side test double is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Linking was a one-way door. The Settings row offered to read the account
whether or not it had already been linked, and there was no way to stop the
six-hourly background read short of clearing the app's data - which also
destroys every imported tag and its history.

A second row appears once there is something to undo, hidden rather than
disabled while there is not: a permanently greyed row in Settings reads as a
feature that is broken rather than one that does not apply yet.

**What it does is narrower than the word suggests, and the dialog says so
before anything happens.** 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 that list to tidy itself up would go looking for a bug,
so the dialog points them at Find My on an Apple device. It also says linking
again needs the Apple device passcode, which is why this asks rather than just
doing it.

On failure the row stays and says so. Reporting an unlink that did not happen
would leave the background read running against an account the user believes
has been let go of.

Seven Espresso tests, verified failable twice: pinning the row visible turns
three red, and skipping the actual forget() turns the two link-state ones red.
One of them holds the "this does not remove the app from your Apple account"
sentence, because that is exactly the caveat somebody trims when shortening a
wordy dialog.

Also records in AGENTS.md what made those tests take 6m 33s before they took
19s: Eventually.perform asks its predicate before the first attempt, so a
predicate phrased as "is the dialog up yet" runs inRoot(isDialog()) against a
screen with no dialog and waits out Espresso's root picker every time. A click
that opens a dialog cannot tear the screen down, so it did not need perform at
all.

testAllOnDevice: 459 instrumented, 199 bridge, 328 wizard, green, 3m 9s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The debug APK is 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 uses one of them.

`-PotvAbi=x86_64` takes it to 68.6 MB, and the saving is roughly double that in
practice because an upgrade needs room for the new APK while the old one is
still installed. @parawanderer hit INSTALL_FAILED_INSUFFICIENT_STORAGE, whose
message says nothing about ABIs.

Opt-in, and hedged twice, because both failure modes here are silent:

- An unrecognised ABI fails the build. Passed through, it would produce an APK
  with no native libraries at all, which installs perfectly happily and dies at
  the first Chaquopy call.
- A release refuses to build while it is set. `providers.gradleProperty` reads
  gradle.properties as well as -P, ~/.gradle included, so somebody who tires of
  typing it and puts it there would get what they wanted locally and a release
  APK that installs on no phone anybody owns - with a green build log.

The release guard is a task-graph check rather than `beforeVariants`, which
runs for every variant whatever was asked for and so failed `assembleDebug`
too, the one command this exists to serve. Verified across all four
combinations: debug with and without, release with and without.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every existing test of this flow replaces ICloudService with a Java fake,
which is right for testing screens and means PythonICloudService itself - the
JSON it builds, the objects it converts, the reason strings it maps - had
never run outside somebody's hands. Two bugs shipped through that gap, and
@parawanderer found both by using the app:

- openFor checked its result with made.toJava(Object.class), which throws for
  any Python object. The flow was dead on every device while the suite stayed
  green, and the screen blamed a missing account.
- getLastReports never emitted wideSearch or exhaustedWideSearch. Java read
  both, a missing key reads as false, and the backoff quietly did nothing.

Both lived in the seam between the two languages, which is the one place a
fake on either side cannot see. @parawanderer's point: everything external
here is behind Python, so mock from the Python side.

icloud_test_double has been sitting in the debug source set unwired since it
was written. This uses it: it replaces the two functions in exporter.icloud
that talk to Apple and nothing else, so the session, the plist rendering and
the failure mapping are all shipping code, and the candidates handed back are
the real dataclasses - the XML Java parses here comes out of the same renderer
a real account's would.

Nine tests covering open, list, unlock, join, a rejected passcode, fetch,
records, rename and close. Verified by reinstating the toJava check, which
turns all nine red - so it would have caught the bug it exists for.

Two things the run itself established, both now named in the test rather than
left as stray lines: unlock resolves its serial against the records cached by
recoveryOptions, so listing first is required and not incidental; and the join
must carry 0PENTAGVIEWR, per rule 11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bugs from the same report. @parawanderer opened a tag showing "No last
location known", paged back a few days in its history, found locations - and
came back to a list still saying "No last location known". Reaching the same
list through the map showed "Last Updated: 3 days ago".

**A first fetch only asked about the last day.** Every window here is derived
from time since the last fetch, capped at 24 hours, which is right for a tag
the app has been watching and wrong for one that arrived five minutes ago from
a zip or from the account. A tag last seen on Tuesday comes back empty from a
window starting this morning, and then sits in My Devices claiming nothing is
known while its locations are on Apple's servers - findable by hand, which is
exactly how this was found.

A beacon with no last_scan_at now gets seven days, which is all Apple keeps.
Per beacon rather than per screen, so it covers the zip route, the account
route and a first launch alike, and it stops after the first search whether or
not that search found anything - keyed on having been searched, because a
genuinely silent tag would otherwise be asked for a full week on every refresh
forever. It cannot push a healthy tag towards the backoff either: ~672 key
indices against the 2000 that makes a search count as expensive.

The manual "look again" on a set-aside tag gets the same week. It was asking
about the last 24 hours of a tag that had been set aside for months of silence,
so the one action the screen offered was near-guaranteed to change nothing.

**And the list only loaded its locations once, in onCreate.** It refreshed
afterwards only when the device page reported a removal or a rename; fetching
history is neither. Now it re-reads them on resume - locations only, because
rebuilding the beacons would re-sort every row under somebody reading them, and
names cannot change without the device page saying so.

Worth noting what this failure looked like: a correct database and a wrong
screen. No repository test could have found it.

Eight tests, all verified failable - stripping the last_scan_at filter turns
two red, disabling the resume refresh turns two red.

testAllOnDevice: 476 instrumented, 199 bridge, 329 wizard, green, 3m 17s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@parawanderer noticed the pins stay white in light mode and dark grey at night
however the app is themed, while the card describing the same tag picks up a
custom colour. They sit on screen together, so the mismatch reads as an
oversight.

The cards tint themselves with ?android:attr/colorBackground; the pins were
filled from R.color.md_theme_background. Those are the same value in every
built-in theme, night included - and stop being the same the moment system
colours are on, because DynamicColors rewrites the theme attribute and cannot
rewrite a fixed value in colors.xml. So they drifted apart on exactly the
setting somebody turns on because they want the app to match their phone.

Resolving the attribute is a no-op wherever they already agreed, and follows
the card everywhere they did not - including whatever themes this app later,
without that theme needing to know these pins exist.

The icon on a pin moves to colorOnSurfaceVariant for the same reason a flat
grey no longer works: once the fill can be anything a wallpaper suggests, a
fixed grey can land on nearly its own shade. Held to 3:1 by a test, because a
pin here once measured 1.23:1 - present, correct and invisible.

Two things found while doing it:

**The emoji was off-centre**, which @parawanderer spotted in the render - low
and left, next to an apple icon that was not. A location pin is a circle with
a point hanging off it, so half the drawable's height is below the middle of
the head, and the emoji was placed there and nudged with two magic divisors.
Both now share one box and Paint measures the glyph rather than a constant
guessing at its width. Asserted by comparing where each pin actually painted
something, so it survives the pin drawable changing shape.

**And a pin could fail to draw at all.** The blur goes through HardwareRenderer
and an ImageReader, and where there is no working GPU render path
acquireNextImage returns null and the exception came out through
showBeaconOnMap - so the tag silently did not appear, with a log line about an
image. It now falls back to the plain outline, which is what the older-Android
branch beside it already did. Found on the headless test emulator; a phone has
a GPU, but losing a shadow beats losing a marker.

Seven tests. Verified failable: putting the colour resource back turns the
themed case red.

testAllOnDevice: 483 instrumented, 199 bridge, 328 wizard, green, 3m 30s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e icon

@parawanderer asked for these grey circles to be lighter. colorOutline is
#6F797A on #F5FAFB - about 4:1, which is right for a one-pixel divider and
heavy for a 24dp fill, so the icon read as a dark blob in a list of light rows.

Softened with a translucent white step rather than a lighter attribute, for
the same reason the two steps below it are: it stays relative to whatever
colorOutline resolves to, so it keeps working under dynamic colours instead of
pinning the icon to a fixed grey the rest of the theme has moved away from. It
lightens the dark theme too, where the surround sat on near-black with contrast
to spare.

**The test meant to stop this going too far could not see it.** It resolved
colorOutline from the theme and compared it to colorSurface - a true statement
about Material's palette that says nothing about this icon, which is the exact
flaw the ladder test beside it documents. It now samples the drawn pixel, in
the same place the ladder test reads the surround.

That turned an assumption into a measurement. 0.15 lands at 3.22:1 and 0.20 at
2.94:1, both read off the failure the test now produces rather than computed by
hand - so 0.15 is the largest step that keeps a graphical object above 3:1, and
anything larger is now caught.

Also bumps androidx.emoji2 1.5.0 to 1.6.0, which is Emoji 16.0 against 15.1 -
@parawanderer asked whether the picker was behind, and it was by one release. A
tag named with an emoji the picker does not know shows a blank box, so this is
worth tracking rather than pinning and forgetting. The bump also moved emoji2's
own minSdk from 21 to 23, below this app's 24.

testAllOnDevice: 483 instrumented, 199 bridge, 329 wizard, green, 3m 10s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three of @parawanderer's own devices were retired on a single first pass.
That is the design working, and one exhaustive search is a thin basis for a
decision that is close to permanent: an ignored tag is skipped by every
automatic fetch afterwards and only comes back if somebody opens it and asks.

A fetch can come back empty for reasons that have nothing to do with the tag -
a request that failed, an account briefly unhappy, a moment where Apple
returned nothing - so it now takes two. They are a refresh cycle apart rather
than back to back, because SCHEDULE_MINUTES[1] is zero, which makes the second
a real further chance for somebody to walk past the thing.

Anything found in between clears the count, so a tag that answers on the
second attempt ends up indistinguishable from one that never missed rather
than carrying a strike towards a retirement it no longer deserves.

The condition is "the previous search also failed" rather than "was also
exhaustive", because nothing records the latter and adding a column means a
Room migration - rule 1 - for a refinement of a heuristic. The difference is
nothing in practice: 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 aims at every consecutive failure is an exhaustive one.

Three tests added, two rewritten. Verified by reverting to retiring on the
first search, which turns exactly the two new guards red.

testAllOnDevice: 486 instrumented, 199 bridge, 329 wizard, green, 3m 24s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MapsActivity had never been launched by any test. The suite runs on aosp-atd,
which has 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.

FakeMapProvider has existed for a while and was only ever driven directly,
which proves the fake works and nothing about the screen. This launches the
activity against a seeded database and asserts it starts and takes the provider
it is given - thin, and the most valuable assertion available, because until
now a crash in onCreate was found by running the app.

**The obvious second assertion is not here, and the reason is worth recording.**
"A tag with a stored location gets a marker" is unreachable without a
restorable Apple session: handleAuthAndShowDevices zips the cached-beacon
stream with PythonAuthService.restoreAccount, so a session that will not
restore disposes the drawing side before it emits. I wrote those tests first
and they failed - the premise, not the code. A blob can be stored, but not one
FindMy.py will deserialise, and that needs a double on the Python side of the
bridge in the shape of icloud_test_double, which does not exist for auth yet.

Written down rather than worked around. The pragmatic alternative - widening
showLastDeviceLocations so a test can call it - buys the assertion by making
the thing under test slightly worse, and pins the drawing while leaving the
path that actually reaches it uncovered. Same reasoning for the marker-dedup
case, also noted in the file.

What the failure did produce is a real assertion: a session that cannot restore
routes back to sign-in rather than leaving somebody on a map that can never
refresh. Pinned because the narrowness of isAccountRestoreFailure is the
load-bearing part - widening it to any restore failure would throw people out
to login for transient reasons, and the cost of being wrong is a re-login
against Apple.

Verified failable: disabling the redirect turns it red.

testAllOnDevice: 488 instrumented, 199 bridge, 328 wizard, green, 3m 22s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last commit recorded that "a tag with a stored location gets a marker" was
unreachable. This is the double that reaches it.

handleAuthAndShowDevices zips the cached-beacon stream with
PythonAuthService.restoreAccount, so a session that will not restore disposes
the drawing side before it emits - and no blob a test writes by hand will
deserialise into a FindMy.py account. apple_test_double replaces getAccount and
accessoryFromJson and nothing else, which is enough because restoring needs no
network: getAccount is AppleAccount.from_json and the sockets only appear at
fetch time. Everything between the two is the shipping code.

Three assertions the app's main screen has never had: a stored location is
drawn, it is drawn at the coordinates the report gives, and a tag nobody has
walked past gets no marker at all. That last one is intended and reads as a
fault from outside - the log line about a tag that "cannot be drawn" sounds
like one - so it is pinned before somebody fixes the non-problem by putting a
marker at 0,0, which is in the Atlantic.

Taking the double back out turns all three red, so the diagnosis in the
previous commit was right rather than merely plausible.

It was not the only thing wrong, though. The first version of these filtered
markers by title, and the screen sets no title - a pin carries the tag's emoji
or icon as its bitmap and nothing else - so the filter matched nothing and read
as "the map drew nothing", which is a long way from the truth. Matched on the
marker's id now, with a note saying why.

testAllOnDevice: 491 instrumented, 199 bridge, 327 wizard, green, 3m 26s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…only

Rule 10: a test suite, or one that needs opting into, gets its index updated.
Both Python-side doubles were added without it - app/src/debug/python/ was not
in the table at all, and nothing said the modules existed. A debug-source-set
Python module that monkeypatches production functions at runtime is not
something anybody finds by reading the code.

The section says what they replace, why they sit below the code under test
rather than in front of it, and what happens without them: the two bugs that
shipped through that seam while the suite stayed green.

Two things it insists on, because both are easy to get wrong. Uninstall belongs
in @after, not at the end of the test body - an uninstall that never runs
leaves the fake in place for every test after it. And neither double tests
Apple: they prove this app is correct about a protocol it cannot check, which
is a different claim from "this still works", and is why rule 2 exists.

Also corrects "tests live in five places", which said five while listing eight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lied

The map tests stopped at the cached locations. This takes them through the
fetch: the session restores, the request crosses the real bridge, Python
serialises what the account returned, the repository stores it, and the screen
redraws the tag where it now is. The two coordinates are a continent apart, so
a fetch that quietly returned the cached report cannot pass.

**FakeMapProvider would have made that test lie.** It invented "marker-0",
"marker-1" and so on, which reads like a reasonable thing for a fake to do and
is wrong: GoogleMapProvider and AMapProvider both return marker.getId() and key
their own maps by it, and MapsActivity depends on that - it calls
removeMarker(beaconId) to replace a tag's pin. Against the fake that matched
nothing and markers accumulated, so a redraw test would have measured the
fake's divergence rather than the app. With the contract honoured, "one marker
and not two" is now asserted rather than deferred.

Two things the failures taught, both kept in the file:

The seeded beacons needed accessory_json. Without it the repository derives one
from the plist, and that plist is shaped like a real one without being 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 no fetch happens.
Silently: the conversion failure is logged per accessory and the batch carries
on with what is left, which is nothing.

Which is why the first assertion is now "python was asked to fetch something".
When this failed, "the pin is in the wrong place" was true and useless. One
question separates "the fetch is wrong" from "there was no fetch".

RefreshPolicy.resetShared() in setup, because it is a process-wide singleton
that survives an activity being rebuilt - so one test's fetch suppresses the
next test's.

testAllOnDevice: 493 instrumented, 199 bridge, 329 wizard, green, 3m 26s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@parawanderer parawanderer modified the milestone: App version 1.1.0 Aug 21, 2026
@parawanderer
parawanderer merged commit 8ca0bc5 into main Aug 21, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant