Always lock wizard bundles, and let a dead CloudKit token reach the sign-in screen - #227
Merged
Merged
Conversation
A bundle holds key material that cannot be revoked - the only way to withdraw an exported accessory is to unpair it - and it travels through a mail account or a chat app and outlives the conversation by years. It was guarded by a ticked checkbox, which is one idle click from an unlocked zip. That click gets made: a user sent @parawanderer their tags in an unlocked bundle. Not an attack and not ignorance of the stakes - it is what an unticked box in the corner of a window produces, eventually, from somebody hurrying. The person the lock protects is exactly the person who would untick it to make a message go away. So the control is gone rather than defaulted on. _write_it calls generate_passcode() unconditionally, and the "this bundle is not locked" branch goes with it, because nothing can reach it. The escape hatch stays on the CLI as --no-password, and its being CLI-only is the point rather than an oversight. Somebody who found a flag, read what it does and typed it has chosen an unlocked bundle. Somebody clicking through a window has not, and giving both the same affordance treats those as the same decision. The release-ordering argument that once justified an opt-out is spent: it protected a recipient on an app too old to open a locked bundle, and every release up to 1.0.5 is refused at sign-in by Apple's edge, so no such recipient exists. The tests assert the control's absence rather than its default - by attribute, and by walking the widgets, so renaming the field and keeping the checkbox fails too. Rule 9 updated; it described the old default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Issue #225, and it is a dead end rather than a wrong sentence. A CloudKit 401 arrived as UNKNOWN, which is the retry screen, and retrying re-runs the identical call. So after a failed sign-in, the Settings button that reconnects the account met the same "could not check your account just now" every time, with nothing on screen offering a way back - in the one flow whose entire job is recovering from this. The only escape was unlinking the account, which nothing says. CREDENTIALS_REJECTED already does the right thing at the other end: it signs out and drops the stored blob so the next screen does not meet the same wall. The failure simply never reached it. UnauthorizedError went unclassified because the type means two opposite things: request_pet raises it when a second factor is being demanded, which is answered with a code and not a sign-out. That is still true and still matters, so this matches the wording rather than the type. Both CloudKit 401 sites in findmy/cloudkit/client.py open with "CloudKit rejected the" and both go on to say to log in again; the request_pet ones are about re-authentication ending in the wrong state and share none of it. Matching a message is unpleasant for the reasons the ValueError above it already documents, and is done for the same reason: the alternative is a screen nobody can leave. A distinct exception type in the fork would be better and is worth doing when the pin next moves. Three tests: both CloudKit wordings sign out, and a 2FA demand emphatically does not. The first two fail without the change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A runner has no ~/.android/debug.keystore, so AGP generates one per run and
every debug APK this repository publishes is signed by a different key.
Two things follow. A debug APK cannot be installed over one from another run
- same applicationId, different key, INSTALL_FAILED_UPDATE_INCOMPATIBLE -
and the only way forward is an uninstall, which with allowBackup false
destroys that device's imported beacons and location history. Testing
successive builds has meant wiping the app every time.
And Google Maps renders blank, because a Maps key is restricted by package
name and signing SHA-1 together, and a SHA-1 that changes every build cannot
be whitelisted. That reads as an API key missing from the build, which is
how it was reported.
CI now writes one keystore from DEBUG_KEYSTORE_BASE64 and the debug signing
config uses app/debug-keystore.jks when it is there. Absent, Gradle logs a
line and falls back to the generated key, so a fork still builds - and so a
missing keystore does not present as an error when maps later come up blank.
The secret goes through `env` rather than into the run block: the secrets
context is not available in a step-level `if` at all, and a ${{ }} inside a
script puts the value on the command line.
The passwords are Android's well-known debug constants on purpose. The key
proves nothing and guards nothing; giving it real secrets would only add
another thing to supply before the project compiles. *.jks was already
ignored.
Whoever has a debug build installed needs one more uninstall, once: it was
signed with a per-run key that no longer exists.
SHA-1 6F:F6:8F:EB:74:AA:3E:1D:DB:8A:C9:49:10:0C:2F:FF:4A:64:CE:33, to be
whitelisted against the Maps key.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Importing tags from the account via Settings left them invisible on the map, and showing in the device list as "No last location known", until the app was closed and reopened. Nothing was wrong with the data - the map reads its tags once, when it is created, and holds them in memory. FetchFromICloudActivity has always announced this. It sets RESULT_IMPORTED on the way out, and both the map and the device list act on it by rebuilding when they start that screen themselves. Settings started it with startActivity, which discards the result, so the one route people actually take to reconnect an account was the one route that dropped the signal. Settings now launches it for a result and carries the flag onward under the same key, so the map reads one name whatever screen it was reached through. The linked/unlinked subtitle is refreshed at the same time; it is read when Settings is built, so it said "not connected" under an account that had just been connected. Two tests: the flag survives the trip, and backing out does not claim an import - a result that always says "imported" costs a full rebuild and a refetch of every tag each time somebody opens that row and changes their mind. Not compiled here; this machine has no Android toolchain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parawanderer
had a problem deploying
to
Android Build
September 16, 2026 14:23 — with
GitHub Actions
Failure
The per-tag history showed a band of the activity's white background below the grey sheet, and clipped the last row of the list above it. insetForSystemBars was applied to the activity root, which pads the coordinator inside it, so the sheet stopped short of the display edge. Its own documentation says not to use it on a screen that draws edge to edge - the map is named as the example - and a bottom sheet is exactly that case. So the bottom inset moves to the list, with clipToPadding=false so it is space the last row can scroll into rather than a dead band that clips it. The root keeps the status bar and the side insets, and the sheet's own background runs to the bottom of the screen again. Added as a paired overload taking both views rather than a top-only helper. A top-only one already existed once and was deleted for cause: it was applied to seven screens and the matching bottom call to one, which is how buttons ended up unreachable behind the gesture pill. Passing both leaves nowhere to put the omission. Six tests, dispatching the insets rather than waiting for them - the values that break this are not the ones a test device reports, and a dispatched inset is the only way to assert what a *second* delivery does. They pin both failures that have shipped: padding that stacked on every rotation, and a root-padded screen whose sheet stopped short. Not compiled here; this machine has no Android toolchain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parawanderer
had a problem deploying
to
Android Build
September 16, 2026 14:27 — with
GitHub Actions
Failure
Two mistakes, one caught by CI and one caught reading the API afterwards. ActivityScenario.Result does not exist; getResult() answers with Instrumentation.ActivityResult, which this file already imports for the stub. That was the compile error. And getResult() throws unless the scenario was created with launchActivityForResult - the ordinary launch() compiles against it perfectly happily and fails at run time, so the emulator would have gone red a second time for a different reason. Both are the cost of writing an instrumented test on a machine with no Android toolchain. Stated rather than hidden: the rest of this branch's Java is unverified here in the same way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parawanderer
had a problem deploying
to
Android Build
September 16, 2026 14:29 — with
GitHub Actions
Failure
There are two battery values in this app and the documentation described only one of them, so a question about the badge on the map was answered from the wrong file - by me, confidently, before checking. BatteryLevelDescription covers the accessory record's field, written by Apple's own devices, and said "nothing outside the debug panel uses any of it". The map's tag cards have shown "Nearby · Battery ..." since the BLE scan landed, and that comes from the tag's own advertisement through FindMyAdvertisement.BatteryLevel - a different source, a different scale (0-3 against this field's 1-4, which reserves 0 for "not reported"), and a different age. They disagree routinely and both can be right. The two decoders also looked contradictory. LocationReportFields refuses to read the status byte unless it conforms to Apple's Table 5-5, and argues at length that decoding an AirTag's 0x90 against that table yields "Low" for a tag whose record says Full. FindMyAdvertisement reads bits 6-7 with no gate at all. That difference is defensible and was undocumented: only the two bits are used and not the rest of the table, so the reserved bits an AirTag sets wrongly are the ones nothing looks at; the one published observation of a real AirTag (0x10, bits 6-7 = full) agrees; and for a user with no Apple device the advertisement is the only source there is. Now written down in all three files, along with the part that matters most - nobody has checked either mapping against a tag at a known charge level, so a reading that disagrees with a fresh battery is as likely to be the decoder as the cell. Comments only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@parawanderer read the history of two tags on one account on 2026-09-16: 0x10 = 0b00010000 on the tag reporting full, 0x50 = 0b01010000 on the tag reporting medium. The useful part is what does not change. The two bytes are identical in every bit except 6 and 7 - including bit 4 set and bit 5 clear, the two that break Apple's Table 5-5 and are the whole reason LocationReportFields refuses to decode this byte. A remainder that is constant across two tags in different battery states is a signature, not a field, so the objection does not reach bits 6-7, and those move with the battery in the order the table gives. Catley's teardown independently records 0x10 on a working tag. So the decoder in FindMyAdvertisement is now evidenced rather than merely defensible, and both files say so. It settles less than it looks like, and that is written down too: the bits are what the accessory claims about itself. Nothing here calibrates the four words against actual charge, and a tag samples its cell on its own schedule, so a freshly replaced battery reporting the old level for a while is expected rather than a decoding fault. That was the question that started this. Comments only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parawanderer
had a problem deploying
to
Android Build
September 16, 2026 14:39 — with
GitHub Actions
Failure
parawanderer
had a problem deploying
to
Android Build
September 16, 2026 14:39 — with
GitHub Actions
Failure
…tale LocationReportFields argued against decoding the accessory status byte, and its headline example was that decoding 0x90 gives "battery Low" for a tag whose own record reads Full - offered as proof the byte lies. It was the wrong way round. @parawanderer's two tags reported 0x90 for months on cells that had not been changed in as long, over both Bluetooth and the Find My network, and replacing the batteries moved them to 0x10 and 0x50. "Low" was correct. The stale value was the accessory record saying Full: that field is written by Apple's own devices, and this user has none, so it had never been written at all. The correction is left visible rather than quietly removed. The claim was written confidently, it was load-bearing for the decision it justified, and what disproved it was somebody changing two batteries and looking. Three of the four states are now recorded in both files, with the remainder 0b_0010000 constant across all three - bit 4 set, bit 5 clear, the two an AirTag sets against Table 5-5. A remainder that does not move across three battery states is a signature rather than a field, so the objection never reached bits 6-7, and those fall in the order the table gives: downward as a cell ages, upward when it is replaced. What is still unknown is narrower than before and said as such: nothing calibrates the four words against actual charge, and 0b11 has never been observed. Comments only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parawanderer
had a problem deploying
to
Android Build
September 16, 2026 14:41 — with
GitHub Actions
Failure
The previous commit disproved the example that justified the conformance gate on the debug row's status byte. It did not change the decision, and a disproved justification with the decision still standing is exactly the shape a later agent removes as an oversight. So the actual reason is written down, and it does not depend on the protocol argument at all: asked directly, @parawanderer's answer was that this is debug metadata and does not need decoding. That row exists so somebody can quote what arrived; the raw byte is certainly right, and a label beside it would be this app's opinion competing with the tag's own on a screen meant for evidence. The battery reading people act on is on the map, from the live advertisement, as one word. Comments only. Last change to this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parawanderer
had a problem deploying
to
Android Build
September 16, 2026 14:43 — with
GitHub Actions
Failure
The emulator run failed on one test out of 747, and it was this one: expected RESULT_OK, got RESULT_CANCELED. finish() skips handleEndActivity(), which is the method that sets the result at all - so the test drove an exit path the app never takes and then reported that the flag had been dropped by a screen nobody asked to report one. The production code was right; the test was pulling the wrong lever. pressBackUnconditionally goes through onBackPressed and therefore through handleEndActivity, which is what a user does. Unconditionally because plain pressBack throws when the activity it finishes is the last one, which here it always is. Third correction to this test, all of them things javac or the device would have told me in seconds on a machine with an Android toolchain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parawanderer
deleted the
fix/always-lock-and-recover-from-dead-token
branch
September 17, 2026 18:54
This branch was successfully 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.
Two unrelated changes on one branch, because they are one CI run instead of two and both are wanted before the releases. Separate commits.
1. The wizard's "Lock with a code" checkbox is gone
A bundle holds key material that cannot be revoked — the only way to withdraw an exported accessory is to unpair it — and it travels through a mail account or a chat app and outlives the conversation by years.
It was guarded by a ticked checkbox, which is one idle click from an unlocked zip. That click gets made: a user sent @parawanderer their tags in an unlocked bundle. Not an attack and not ignorance of the stakes — it is what an unticked box in the corner of a window produces, eventually, from somebody hurrying.
So the control is gone rather than defaulted on.
_write_itcallsgenerate_passcode()unconditionally, and the "this bundle is not locked" branch goes with it because nothing can reach it. The escape hatch stays on the CLI as--no-password: somebody who found a flag and typed it has chosen an unlocked bundle; somebody clicking through a window has not.The tests assert the control's absence rather than its default — by attribute, and by walking the widgets, so renaming the field and keeping the checkbox fails too. Rule 9 updated, since it described the old default.
2. A dead CloudKit token no longer traps the user
Closes #225, which turned out to be a dead end rather than a wrong sentence.
A CloudKit 401 arrived as
UNKNOWN, which is the retry screen, and retrying re-runs the identical call. After a failed sign-in, the Settings button that reconnects the account met the same "could not check your account just now" every time, with nothing offering a way back — in the one flow whose entire job is recovering from this. The only escape was unlinking the account, which nothing tells you.CREDENTIALS_REJECTEDalready does the right thing at the other end — signs out, drops the stored blob. The failure never reached it.UnauthorizedErrorwent unclassified because the type means two opposite things:request_petraises it when a second factor is being demanded, answered with a code and not a sign-out. That is still true, so this matches the wording, not the type. Both CloudKit 401 sites open withCloudKit rejected theand both say to log in again; therequest_petones are about re-authentication ending in the wrong state and share none of it.Matching a message is unpleasant for the reasons the
ValueErrorbeside it already documents, and is done for the same reason: the alternative is a screen nobody can leave. A distinct exception type in the fork would be better, and is worth doing when the pin next moves.Three tests — both CloudKit wordings sign out, a 2FA demand emphatically does not. The first two fail without the change, verified by removing it.
306 bridge tests and 502 exporter tests pass; flake8 clean. Not verified here: no Android toolchain on this machine, so nothing Java was compiled.
🤖 Generated with Claude Code