Skip to content

Read either kind of tag back, and pin one FindMy.py for the whole repo - #106

Merged
parawanderer merged 3 commits into
mainfrom
feat/import-a-self-generated-tag
Aug 16, 2026
Merged

parawanderer merged 3 commits into
mainfrom
feat/import-a-self-generated-tag

Conversation

@parawanderer

@parawanderer parawanderer commented Aug 16, 2026 •

Copy link
Copy Markdown
Owner

The Python half of handover §4. A self-generated tag can now be read back; importing one still
needs the Java side, which is the next piece.

The bug

Every stored accessory was deserialized with FindMyAccessory.from_json, which is the wrong
class for an OpenHaystack-style tag — and it asserts its own type tag, so such a tag could be
imported and then never located.

The two are sibling classes, not a base and a subclass. An Apple-paired accessory derives
its keys from a master key and two secrets; a self-generated one carries a plain list of
pre-generated keys and derives nothing. FindMy.py has no factory that reads both, so the
dispatch is ours — keyed by the library's own type tag, which each from_json asserts, so a
mapping handed to the wrong class fails loudly rather than half-loading.

Why it is this small

Everything else the fetch path does was already polymorphic. keys_between,
get_min_index, get_max_index, update_alignment and to_json exist on both classes, so
deserialization was the only place the difference mattered — and the rest of the path never
learns which kind it has.

The annotations now say StoredAccessory, a union of the two, rather than their shared
RollingKeyPairSource: that base carries the key methods but not to_json, which arrives
from Serializable down a separate line. pyright caught that, and the union is the honest
description.

The upgrade question, asked before changing anything

Every beacon an existing user owns is stored as the Apple-paired kind, so "does a stored row
still load" is the question with something to lose.

It does, and the check is not new strictness: FindMy.py's own from_json already does
assert val["type"] == "accessory", so a row without one would be failing today. Existing
rows have it. There is a test that an Apple-paired tag loads and still round-trips — the fetch
path writes to_json() back after every call, so a tag that reads but does not re-read would
work once and fail on the second refresh.

Tests

Two existing stubs monkeypatched FindMy.py's class to hand back a fake from a blob with no
type at all — which is how they got away with feeding the real thing something it has always
rejected. They now replace the dispatch instead, which is the seam they actually mean and stops
them mutating a library class.

Checked they can fail: swapping the two entries in the table reddened three tests — one for
the upgrade case, two for the new one.

94 Python tests pass, 206 instrumented tests pass, pyright and flake8 clean.

Also here

One commit carrying an AGENTS.md addition to rule 11, left over from #104: sending the same
value is not the same as sending the same bytes
. FindMy.py transforms X-Apple-I-MD-LU and
X-Mme-Device-Id on the way out and the Java ADI path does not, so "pass the same string"
aligns one field and silently leaves the other as two. It rode along here rather than as a
docs-only PR, which would have had a rollup of zero checks.

Also: one FindMy.py for the whole repository

The exporter tracked the branch while the app pinned a commit, so the two drifted by
construction — every uv lock picked up whatever had been pushed since. Today they were
47c4f89 and 102dd8e. Each build was reproducible; "which FindMy.py does this repository
use" simply had two answers.

That matters more here than in a repo of two unrelated programs, because opentagviewer_export
is shared code that runs under both
— Chaquopy packages it into the APK against
app/build.gradle.kts, and the exporter runs the same files from source against python/uv.lock.
One package behind two library versions breaks exactly one of its two consumers, on the first API
difference, and nothing would say which.

python/pyproject.toml names a rev now, and a test asserts all four places agree — the app
build, the bridge requirements, pyproject.toml and uv.lock. It reports which file disagrees
and what each says, because "they differ" is not the useful failure.

Checked before moving it that findmy/accessory.py is unchanged between the two commits, so the
mapping the exporter writes is byte-identical to what the app reads. The diff is confined to the
identity work, which the exporter deliberately does not use — it has no ADI provisioning of its
own, so there is no second half to contradict, and python/exporter/identity.py says so.

Verified by drifting pyproject.toml back one commit and watching the test name it.

Unrelated, found while running the exporter's suite: test_scriptable.py has 7 Windows-only
failures. exporter/secrets.py refuses a file "readable by other users", which no Windows temp
file satisfies. They fail identically on a pristine tree, so they predate this — worth a separate
look, since it means the exporter's tests cannot be run to green on Windows.

Next, for a self-generated tag

  • CustomAccessories/<identifier>.json in AppleZipImporterUtil.MATCHERS — note the identifier
    is not a UUID, so the existing v4 regex does not apply
  • somewhere to store it: OwnedBeacon.content assumes a plist, accessoryJson is the natural
    home
  • a Room migration, which is rule 1 and the one mistake with no recovery

parawanderer and others added 2 commits August 16, 2026 19:07
Rule 11 said the identity has to be one identity and left "the same" undefined.
It is not obvious, and getting it wrong produces nothing to notice: FindMy.py
sends X-Apple-I-MD-LU as base64(uid) and uppercases X-Mme-Device-Id, while the
Java ADI path sends what it is handed. Passing Java's string to Python therefore
aligns one field and leaves the other as two different values - alignment that
reads as alignment and is not.

Nothing complains about that, on either side, ever. It was caught only because
the upstream parameter docs said to check, and then only by reading what the
library actually does with the value rather than what it is called.

Also states where the six identity values now live, since "one source of truth"
was a principle with no address attached.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fetch path deserialized every stored accessory with
FindMyAccessory.from_json, which is the wrong class for a self-generated tag and
asserts its own type tag - so a bundle carrying one could be imported and then
never located.

The two are sibling classes, not a base and a subclass. An Apple-paired accessory
derives its keys from a master key and two secrets; an OpenHaystack-style one
carries a plain list of pre-generated keys and derives nothing. FindMy.py has no
factory that reads both, so the dispatch is ours, keyed by the library's own type
tag - which each from_json asserts, so a mapping handed to the wrong class fails
loudly rather than half-loading.

**Everything else the fetch path does was already polymorphic.** keys_between,
get_min_index, get_max_index, update_alignment and to_json are on both classes, so
deserialization was the only place the difference mattered and the rest of the
path never learns which kind it has. The annotations say StoredAccessory now - a
union of the two, rather than their shared RollingKeyPairSource, because that base
carries the key methods but not to_json.

Checked before changing it that this is not a new strictness for anyone: FindMy's
own from_json already asserts val["type"] == "accessory", so a stored row without
one would be failing today. Existing rows have it, and there is a test that an
Apple-paired tag still loads and still round-trips - which is the case with
something to lose, since every beacon an existing user has is stored that way.

Two test stubs monkeypatched FindMy.py's class to hand back a fake from a blob
with no type at all. They now replace the dispatch instead, which is the seam
those tests actually mean and stops them mutating a library class.

Verified by swapping the two entries: three tests went red, one for the upgrade
case and two for the new one. 94 Python tests pass, pyright and flake8 clean.

Still to come for a self-generated tag: the importer's own directory matcher and
somewhere to store it. This is the half that had no Java in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The exporter tracked the branch while the app pinned a commit, so the two drifted
by construction: every `uv lock` picked up whatever had been pushed since. The
lockfile made each *build* reproducible, but "which FindMy.py does this repository
use" had two answers, and today they were 47c4f89 and 102dd8e.

That matters more here than in a repository of two unrelated programs, because
`opentagviewer_export` is shared code that runs under both. Chaquopy packages it
into the APK, where it executes against the pin in app/build.gradle.kts, and the
desktop exporter runs the same files from source against python/uv.lock. One
package behind two library versions breaks exactly one of its two consumers, on
the first API difference, and nothing would say which.

So python/pyproject.toml names a rev rather than a branch, and a test asserts all
four places agree - app/build.gradle.kts, the bridge requirements, pyproject.toml
and uv.lock. It reports which file disagrees and what each one says, because the
useful failure is not "they differ".

Checked before moving it that accessory.py is unchanged between the two commits,
so the custom_rolling_key_accessory mapping the exporter writes is byte-identical
to what the app now reads. The diff is confined to account.py, anisette.py and the
keychain modules - the identity work - which the exporter deliberately does not
use: it has no ADI provisioning of its own, so there is no second half to
contradict, and python/exporter/identity.py says as much.

Verified by drifting pyproject back one commit and watching the test name it. 95
bridge tests pass; the exporter's own suite has 7 pre-existing Windows-only
failures in test_scriptable.py, unrelated to this - secrets.py refuses a file
readable by other users, which no Windows temp file satisfies. They fail
identically on a pristine tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@parawanderer parawanderer changed the title Read either kind of tag back, not only the Apple-paired one Read either kind of tag back, and pin one FindMy.py for the whole repo Aug 16, 2026
@parawanderer
parawanderer merged commit efe801b into main Aug 16, 2026
8 of 9 checks passed
saamdotexe pushed a commit to saamdotexe/OpenTagViewer that referenced this pull request Oct 22, 2026
…elf-generated-tag

Read either kind of tag back, and pin one FindMy.py for the whole repo

This branch was previously deployed

1 inactive deployment
Android Build — 5376722a Deployed Aug 16, 2026 by parawanderer via Instrumented tests (emulator) #112
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