Read tags from an Apple account, and show one nothing ever named - #118
Merged
Conversation
`exporter/icloud.py` was written when the desktop exporter was its only caller, so it read `exporter.identity` directly and built FindMy.py's own `LocalAnisetteProvider`. Neither survives the move to Android: that provider reaches `unicorn`, a CPU emulator Chaquopy cannot build, and a second program presenting the exporter's serial is the same device to Apple - so removing one from the device list breaks the other (AGENTS.md rule 11). So three seams, each defaulted to exactly what the desktop already did: - `ClientIdentity`, carrying the serial and the CloudKit device name, defaulting to `EXPORTER_IDENTITY` - `provider=`, to hand in Anisette produced elsewhere - which is the only way this runs on Android at all, since the app makes it from Apple's real ADI libraries in-process - `identity_path=`, so the app keeps its device identity in its own storage Only the four modules that are stdlib-plus-FindMy.py are packaged into the APK, named rather than globbed: a module added to `exporter/` later should have to be considered before it ships, not swept in. The wizard, the CLI and the prompts stay out, and a test says so by name. PyYAML comes with it. `exporter.icloud` imports `AccessoryExport`, which resolves through `opentagviewer_export.bundle`, which reads and writes `OPENTAGVIEWER.yml`. The app does not need it to read an account today - Java parses the yaml on import - but it will need it in earnest as the third producer that writes bundles of its own. Pinned to the same version as `python/pyproject.toml`, so one repository ships one PyYAML. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Importing a self-generated tag reported "1 device imported" and then showed nothing, on any screen. The tag was in the database and was entirely healthy - the fetch path found it and collected reports for it happily, which is why nothing looked broken from the log. `BeaconCombinerUtil.combine` iterated the *naming records* and looked the owned beacon up from there. A `BeaconNamingRecord` is what an Apple device wrote *about* a tag; a tag that was never in an Apple account has none, because nobody's iPad ever named it. So it was dropped before the parser was ever asked about it - by the one step between the database and every list. Turned around to iterate the owned beacons, which is what a tag actually is, left-joining the naming record. Nothing changes for an Apple tag: the importer inner-joins the two sets, so each side has exactly what the other does. This direction is in fact the safer one - the old one could hand out a `BeaconData` whose `ownedBeaconInfo` was null, which is the half nothing downstream can work without, and `BeaconDataParser` now skips a row it cannot name rather than throwing part-way through and emptying the whole screen. **Every existing test passed throughout, and that is the interesting part.** They each built a `BeaconData` by hand and handed it to the parser, so none of them ever went through the join that decides what reaches a screen - the tested thing worked and the shipped thing did not. Three new cases go through it, and reinstating the old direction turns exactly those three red. A fourth guards the mirror-image mistake, since driving from the owned beacons could just as easily have left the naming record behind and cost every real tag its name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bridge between Java and `exporter.icloud`: open a Find My client, ask what the keychain can
be recovered from, unlock it with a device passcode, read the accessories. Four steps rather
than one call because a person has to answer something between them, held on a session object
because two of the steps own sockets.
Three decisions worth the words:
**Failures are values, not exceptions.** A raised Python exception reaches Java as a
`PyException` whose message is whatever `str()` gave - which is the empty string for several
that matter here. This app has already shipped a dialog reading `Login failed:` and then
nothing. So every step returns `{"ok": false, "reason": ..., "message": ...}`, the reason is
what Java branches on so wording stays translatable, and a test asserts that even a bare
`TimeoutError` arrives with words in it.
**Listing and taking are separate calls.** A picker needs names; it does not need everybody's
private keys rendered into a JSON string to draw a list of them. So `fetch` describes what is
there and `records` returns the documents for what was picked - and `close` drops the decrypted
candidates with the client.
**Nothing invents a name, and nothing demands one.** `to_export` refuses a nameless accessory,
correctly: a *bundle* is inner-joined by its importer, so one exported without a name goes
silently missing. None of that applies to an account read straight into the app. It left-joins,
it already knows how to show a tag nothing ever named, and `UserBeaconOptions` is how anything
gets renamed. So the naming record passes through as null, and `BeaconDataParser` shows such a
tag by what its own record says it is - "AirTag" rather than a blank row - instead of skipping
it.
The account is the one the app is already signed in with, not a second one restored from the
same JSON: one install is one device to Apple, and a parallel copy would put a second client on
the wire under the same name. The CloudKit identity is this app's `0PENTAGVIEWR`, never the
exporter's `0PENTAGXPORT` - rule 11, asserted in the Python tests and again inside the APK,
which is the only place it is real.
30 tests against fakes cover the paths a real account could not: nothing to recover from, a
service having a bad day, a rejected passcode. `PythonPackagingTest` covers the half they
cannot - that the module survives packaging at all, since it reaches `findmy.cloudkit` and
therefore protobuf.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer
temporarily deployed
to
Android Build
August 19, 2026 19:10 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 19, 2026 19:10 — with
GitHub Actions
Inactive
The section named `main.py`, which was true when it was the only module in there. It is now three, and one of them - `icloud_bridge` - is the first to reach `findmy.cloudkit` and therefore protobuf. That is exactly the kind of dependency that resolves on a laptop and is absent on a phone, so the note also says which suite answers that half: these run on CPython and cannot, `PythonPackagingTest` runs in the APK and does. The test table needed no new row - `app/src/test/python/` was already a line in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer
temporarily deployed
to
Android Build
August 19, 2026 19:11 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 19, 2026 19:11 — with
GitHub Actions
Inactive
§7 describes it as the fetch coming back empty. It never gets that far: an account with no Apple device has no escrow record either, so the flow stops a step earlier, at unlocking the keychain - before the user is asked for a passcode they do not have. The copy is right and the trigger was wrong. That makes the distinction the bridge already draws load-bearing rather than incidental. "Nothing on this account can be recovered from" is final and the import path is the answer; "nothing was reported usable at all" is a service having a bad day. Showing the first for the second tells somebody with a perfectly good account that they permanently own no tags, and sends them off to find a friend with a Mac. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer
temporarily deployed
to
Android Build
August 19, 2026 19:15 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 19, 2026 19:15 — 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.
The Python half of §5 in
docs/android-import-handover.md— the app running the iCloud pipeline itself, instead of a round trip through a Mac and a zip. Plus one bug that turned up while testing it, which is user-visible today.The bug, first
Importing a self-generated tag reported "1 device imported" and then showed nothing, anywhere.
The tag was in the database and entirely healthy — the fetch path found it and collected reports for it happily.
BeaconCombinerUtil.combineiterated the naming records and looked the owned beacon up from there, and aBeaconNamingRecordis what an Apple device wrote about a tag. A tag that was never in an Apple account has none, so it was dropped by the one step between the database and every list.Turned around to iterate the owned beacons, left-joining the naming record. Nothing changes for an Apple tag — the zip importer inner-joins the two, so each side has exactly what the other does — and this direction is the safer one anyway: the old one could hand out a
BeaconDatawhoseownedBeaconInfowas null.Every existing test passed throughout, and that is the interesting part. They each built a
BeaconDataby hand and handed it to the parser, so none went through the join that decides what reaches a screen. Three new cases do; reinstating the old direction turns exactly those three red. A fourth guards the mirror-image mistake.The glue
exporter/icloud.pywas written when the desktop exporter was its only caller. Two things did not survive the move to Android: it built FindMy.py's ownLocalAnisetteProvider, which reachesunicornand can never run there, and it presented the exporter's serial. Three seams, each defaulted to what the desktop already did —ClientIdentity,provider=,identity_path=.Then
icloud_bridge.py, the app's side of it: open a client, ask what the keychain can be recovered from, unlock with a device passcode, read the accessories.Three decisions worth stating:
PyExceptionwhose message is whateverstr()gave — the empty string, for several that matter. This app has already shipped a dialog readingLogin failed:and then nothing. Every step returns{"ok": false, "reason": ..., "message": ...}, and a test asserts that even a bareTimeoutErrorarrives with words in it.to_exportrefuses a nameless accessory, correctly — a bundle is inner-joined by its importer, so one exported without a name goes silently missing. That does not apply to an account read straight into the app. The naming record passes through as null, andBeaconDataParsernow shows such a tag by what its own record says it is rather than skipping it.The account is the one the app is already signed in with, not a second one restored from the same JSON: one install is one device to Apple. The CloudKit identity is
0PENTAGVIEWR, never the exporter's0PENTAGXPORT— rule 11.What is verified, and what is not
icloud_bridgeimports inside the APK, reporting0PENTAGVIEWRPythonPackagingTestThat last row is the honest limit of this PR. The flow needs a real Apple ID, a real device with a passcode and a real escrow record, none of which are available here — so what is proven is that the module packages, imports, presents the right identity, and behaves correctly on every path a fake can drive, including the ones a real account would rarely reach: nothing to recover from, a service having a bad day, a rejected passcode. Whether Apple accepts the exchange is unproven and needs a person with an account to find out.
No UI yet — §7's screens are the next piece, and the connect/import choice at sign-in is still an open design question.
This PR description was written by Claude Code.