Read tags from an Apple account, and stop the database deleting them - #125
Merged
Conversation
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 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>
parawanderer
temporarily deployed
to
Android Build
August 21, 2026 10:41 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 21, 2026 10:41 — with
GitHub Actions
Inactive
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>
parawanderer
temporarily deployed
to
Android Build
August 21, 2026 10:52 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 21, 2026 10:52 — with
GitHub Actions
Inactive
…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>
parawanderer
temporarily deployed
to
Android Build
August 21, 2026 10:55 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 21, 2026 10:55 — with
GitHub Actions
Inactive
…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
temporarily deployed
to
Android Build
August 21, 2026 11:05 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 21, 2026 11:05 — with
GitHub Actions
Inactive
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Bugs fixed
INSERT OR REPLACEwas deleting the rows it was meant to update. SQLite's REPLACE is a delete plus an insert, and Room enables foreign keys, so it firedON DELETE CASCADEinto all three children ofOwnedBeacons. 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 andallowBackupis false.addNewImporthas 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
wideSearchandexhaustedWideSearch; onlygetReportswrote them, and the app callsgetLastReports. A missing key reads asfalse, 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 replacedICloudServicewith a Java fake, soPythonICloudService— 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 inexporter.icloudthat 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
MapsActivitynow 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.🤖 Generated with Claude Code