Skip to content

Always lock wizard bundles, and let a dead CloudKit token reach the sign-in screen - #227

Merged
parawanderer merged 11 commits into
mainfrom
fix/always-lock-and-recover-from-dead-token
Sep 16, 2026
Merged

parawanderer merged 11 commits into
mainfrom
fix/always-lock-and-recover-from-dead-token

Conversation

@parawanderer

Copy link
Copy Markdown
Owner

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_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: 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_REJECTED already does the right thing at the other end — signs out, drops the stored blob. The failure never reached it.

UnauthorizedError went unclassified because the type means two opposite things: request_pet raises 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 with CloudKit rejected the and both 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 beside 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

parawanderer and others added 2 commits September 16, 2026 16:12
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>
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>
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 and others added 2 commits September 16, 2026 16:37
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>
…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>
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>
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
parawanderer merged commit a94cea9 into main Sep 16, 2026
9 checks passed
@parawanderer
parawanderer deleted the fix/always-lock-and-recover-from-dead-token branch September 17, 2026 18:54

This branch was successfully deployed

1 active deployment
Android Build — 94893b55 Deployed Sep 16, 2026 by parawanderer via Instrumented tests (emulator) #291
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.

App: a dead CloudKit token is reported as "could not check your account just now"

1 participant