Repository navigation
Read either kind of tag back, and pin one FindMy.py for the whole repo - #106
Merged
Merged
Conversation
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>
parawanderer
temporarily deployed
to
Android Build
August 16, 2026 17:14 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 16, 2026 17:14 — with
GitHub Actions
Inactive
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
temporarily deployed
to
Android Build
August 16, 2026 17:24 — with
GitHub Actions
Inactive
parawanderer
had a problem deploying
to
Android Build
August 16, 2026 17:24 — with
GitHub Actions
Failure
parawanderer
temporarily deployed
to
Android Build
August 16, 2026 17:28 — with
GitHub Actions
Inactive
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
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 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 wrongclass 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
typetag, which eachfrom_jsonasserts, so amapping 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_alignmentandto_jsonexist on both classes, sodeserialization 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 sharedRollingKeyPairSource: that base carries the key methods but notto_json, which arrivesfrom
Serializabledown a separate line. pyright caught that, and the union is the honestdescription.
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_jsonalready doesassert val["type"] == "accessory", so a row without one would be failing today. Existingrows 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 wouldwork 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
typeat all — which is how they got away with feeding the real thing something it has alwaysrejected. 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.mdaddition to rule 11, left over from #104: sending the samevalue is not the same as sending the same bytes. FindMy.py transforms
X-Apple-I-MD-LUandX-Mme-Device-Idon 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 lockpicked up whatever had been pushed since. Today they were47c4f89and102dd8e. Each build was reproducible; "which FindMy.py does this repositoryuse" simply had two answers.
That matters more here than in a repo of two unrelated programs, because
opentagviewer_exportis 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 againstpython/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.tomlnames arevnow, and a test asserts all four places agree — the appbuild, the bridge requirements,
pyproject.tomlanduv.lock. It reports which file disagreesand what each says, because "they differ" is not the useful failure.
Checked before moving it that
findmy/accessory.pyis unchanged between the two commits, so themapping 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.pysays so.Verified by drifting
pyproject.tomlback one commit and watching the test name it.Next, for a self-generated tag
CustomAccessories/<identifier>.jsoninAppleZipImporterUtil.MATCHERS— note the identifieris not a UUID, so the existing v4 regex does not apply
OwnedBeacon.contentassumes a plist,accessoryJsonis the naturalhome