Skip to content

Read tags from an Apple account, and show one nothing ever named - #118

Merged
parawanderer merged 5 commits into
mainfrom
feat/icloud-glue-for-the-app
Aug 19, 2026
Merged

Read tags from an Apple account, and show one nothing ever named#118
parawanderer merged 5 commits into
mainfrom
feat/icloud-glue-for-the-app

Conversation

@parawanderer

Copy link
Copy Markdown
Owner

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.combine iterated the naming records and looked the owned beacon up from there, and a BeaconNamingRecord is 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 BeaconData whose ownedBeaconInfo was null.

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 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.py was written when the desktop exporter was its only caller. Two things did not survive the move to Android: it built FindMy.py's own LocalAnisetteProvider, which reaches unicorn and 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:

  • Failures are values, not exceptions. A raised Python exception reaches Java as a PyException whose message is whatever str() gave — the empty string, for several that matter. This app has already shipped a dialog reading Login failed: and then nothing. Every step returns {"ok": false, "reason": ..., "message": ...}, and a test asserts that even a bare TimeoutError arrives with words in it.
  • Listing and taking are separate calls. A picker needs names, not everybody's private keys rendered into a JSON string so a list can be drawn.
  • 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. That does not apply to an account read straight into the app. The naming record passes through as null, and BeaconDataParser now 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's 0PENTAGXPORT — rule 11.

What is verified, and what is not

244 instrumented tests pass on the managed device
148 Python tests, 30 of them new pass
icloud_bridge imports inside the APK, reporting 0PENTAGVIEWR asserted by PythonPackagingTest
The three new join tests genuinely fail without the fix confirmed by reinstating the old direction
The self-generated tag now appearing in the app reproduced from @parawanderer's own report and the device log
The iCloud flow against a real Apple account not run. Every bridge test is against fakes

That 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.

parawanderer and others added 3 commits August 19, 2026 20:17
`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>
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>
§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
parawanderer merged commit a6ff7a7 into main Aug 19, 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