Skip to content

Say why a sign-in failed, and add the seam a map test needs - #114

Merged
parawanderer merged 6 commits into
mainfrom
feat/fake-map-provider
Aug 19, 2026
Merged

Say why a sign-in failed, and add the seam a map test needs#114
parawanderer merged 6 commits into
mainfrom
feat/fake-map-provider

Conversation

@parawanderer

@parawanderer parawanderer commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Branched from #113, so its commits appear below too. Merge that first; the diff collapses to
the last two.

"Login failed:" and nothing after the colon

Reported from a real device. The cause is small and the effect is not: Python returned str(e),
and the failure people actually hit is a connection timeout — str(TimeoutError()) is the empty
string.
Several exceptions on this path carry no message at all, asyncio's especially.

So the screen could not distinguish a wrong password from a dead network from a broken app —
three different things to do next.

Python classifies rather than stringifies

A failure that never reached Apple is network. Anything else is left unclassified on purpose
— telling somebody to check their connection when their password was wrong sends them to fix the
wrong thing. Matched on exception type and module, not message text, because the messages are
empty or English prose from three libraries down.

describeLoginFailure also guarantees a non-empty detail by falling back to the type name, since
an empty string is how this started.

Java chooses the sentence

The reason is a code, so the text can be translatedstr(e) never could be. A recognised
reason gets a sentence saying what to do; anything else falls back to the detail, which at least
names the exception. Unhelpful but honest, and better than guessing at a cause.

Testing

Two Espresso tests assert the words on screen, not that an error appeared — the old version
showed an error too, it just said nothing. FakeAppleAuthService.cannotReachApple() reproduces
the exact shape: a failure carrying no message.

Verified by restoring the old raw-message rendering: the network case went red and the fallback
case stayed green, which is correct, since that one always had a message.

234 instrumented, 108 Python.


What the diagnosis actually found — it was not the identity work

Worth recording, because #104 was the obvious suspect and it is exonerated. From the device log:

adi: ADIProvisioningStart ok: session 1963936487, 276 bytes of client metadata
adi: ADIProvisioningEnd(session 1963936487) = 0
LocalAnisette: generated a new device identity as IPHONE - this must now be kept
adi: ADIOTPRequest ok: 60 byte machine id, 28 byte password

A fresh install on the new path — iPhone profile, base64 X-Apple-I-MD-LU — provisioning with
Apple and being accepted. Then the login timed out in aiohttp.

Three things compounding, none of them ours:

ClientSession(timeout=ClientTimeout(total=5)) FindMy allows 5 seconds total per request, not configurable
emulator RTT to Apple ~500 ms
emulator IPv6 fec0::/10 — site-local, routes nowhere, so happy-eyeballs burns the budget on it

Elapsed in the log was 5.75s. Provisioning survived the same network because that is Java's
HttpURLConnection with the 30-second timeouts — same network, six times the patience. And the
CLI works because it runs on the host, not inside the emulator's NAT.


Also here: a seam for a fake map provider

MapProviderFactory gains a test-only override, plus a FakeMapProvider that records markers,
polylines and camera moves instead of drawing. Rule 7 made this cheap — one more implementation
rather than a change to any screen.

And it produced a finding worth more than the code. Probing whether MapsActivity can start
on aosp-atd established that Play Services is not the blocker — the screen reaches map
initialisation happily without it. What stops it is that it needs a usable signed-in session,
and restoring one goes through PythonAuthService.restoreAccount, which is static with no seam. A
stored blob that is not a real encrypted session fails to restore, and the screen redirects to
login before drawing.

So the end-to-end journey test needs an account seam next, not a map one. That probe is not
included — it only passed when run alone — and the finding is recorded in the test that is.


PR description written by Claude Code

parawanderer and others added 3 commits August 19, 2026 11:12
…rrowly

Handover §2. BeaconInformation.isAirTag() and isIpad() are the older, narrower
version of what opentagviewer_export/hardware.py does: a product id and a substring
match. Everything else - AirPods, and which unit it is, third-party tags the
Bluetooth SIG registry knows by maker, anything unrecognised - arrived on the
device screen as "Unknown".

The heuristic is not ported, deliberately. It is guesswork over half a dozen fields
and its vendor list grows as accessories turn up, so two copies means two things to
update and one of them goes stale - and the symptom is one tag described as an
AirTag on one screen and as a hex number on the other. The bridge into it already
existed and was already tested from the APK; nothing in Java had ever called it.

**The screen draws what it knows first, then improves.** The call starts a Python
interpreter and parses a plist, so it cannot be on the main thread - and a screen
that showed nothing until Python answered would flash "Unknown" and correct itself.
So knownDeviceType() renders immediately and the heuristic only ever replaces it
with something better.

A null answer means nothing recognised the record, and then the label already on
screen stands. That asymmetry is the reason: a wrong name is believed, where a hex
number gets looked up. Nothing here throws either, because the caller has already
drawn a label and turning that into a crash is a strictly worse trade.

Behind an interface and in AppDependencies like the rest, so a screen using it can
still be launched in a test, and so "an accessory nothing recognises" is renderable
on demand rather than needing such a tag to exist.

A self-generated tag short-circuits: it has no plist, and it already describes
itself from §4.

The in-flight lookup is held and disposed in onDestroy - it hops back to the main
thread to set a label, and the screen may be gone by then.

Four tests on the Java side of the bridge, which is the part that is new; the
heuristic itself is tested in python/ and its reachability from the APK in
PythonPackagingTest. Verified by discarding the bridge's answer, which reddened the
one test that asserts a real name comes back.

One test was renamed rather than kept: it claimed nothing crosses the bridge for a
tag with no plist, which nothing in it can observe - Python would answer None for a
null plist anyway. It now says what it actually checks.

219 instrumented tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every tag without an emoji fell back to `@drawable/apple` - a Chipolo, a
Pebblebee, and an OpenHaystack-style tag whose keys have never been near an Apple
account. That is not a bland default, it is a wrong one: the icon is the only place
the app says anything about where a tag came from, and it said the same thing about
all of them.

Three icons now, by provenance: Apple's own hardware keeps Apple's logo, anything
else findable gets concentric arcs, and a self-generated tag gets a haystack with a
needle in it.

**Neither new icon borrows a mark.** Apple's Find My logo and OpenHaystack's are
their branding, and this app is handed around outside any store - shipping Apple's
Find My mark in something that already tells their servers it is an iPhone is not a
fight worth picking. Both are drawn here and evoke the idea instead.

**Decided from the vendor id, not from the shared heuristic.** The heuristic gives a
better name but costs a Python interpreter and answers asynchronously, and an icon
that arrives late is an icon that visibly changes under the user. The vendor id is
already on the row. An unknown vendor is treated as third-party rather than Apple,
which is the honest way round - claiming Apple for something unidentified is the
exact wrong answer this replaces.

One resolver, because there are three surfaces - the map carousel, the device list
and the device screen - and each named the drawable itself. Three copies of a
default is how two of them go stale.

Two things found on the way:

**The device list had a recycling bug.** Only the emoji branch set anything, so a
recycled row reused from a tag that had an emoji kept showing that tag's emoji.
Both branches now set both views.

**The haystack was wrong the first time, and only rendering it showed that.** The
mound was outlined rather than filled, which read as an igloo or a gauge, the straw
looked like tally marks and the ground line floated as an unrelated bar. It is
filled now, with the straw as cut-outs, and no ground. The rendered sheets are what
established both versions - "covers some pixels" is a long way from "reads as a
haystack".

Also drops the default haystack *emoji* added with the self-generated tag import.
It kept the row from being blank but sent it down the emoji path and past the icon,
which is what this replaces.

Nine tests: which icon each kind gets, that an unknown vendor is not assumed to be
Apple, that being self-generated wins over any vendor id, and that all three
actually paint something in both light and dark - loaded through AppCompatResources
the way the screens load them, not with a null theme, which is how the history
timeline once shipped invisible with a green screenshot test.

228 instrumented tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The screen showed "Login failed:" with an empty message. The cause is small and
the effect is not: Python returned `str(e)`, and the failure people actually hit is
a connection timeout - `str(TimeoutError())` is the empty string. Several of the
exceptions that reach this path carry no message at all, asyncio's especially.

So somebody looking at that screen could not tell a wrong password from a dead
network from a broken app, which are three different things to do next.

Two halves.

**Python classifies rather than stringifies.** A failure that never reached Apple
is reported as `network`; anything else is left unclassified on purpose, because
telling somebody to check their connection when their password was wrong sends
them to fix the wrong thing. Matched on exception type and module rather than on
message text - the messages are empty or English prose from three libraries down.
`describeLoginFailure` also guarantees a non-empty detail by falling back to the
exception's type name, since an empty string is how this started.

**Java chooses the sentence.** The reason is a code, so the text can be
translated; `str(e)` never could be. A recognised reason gets a real sentence
saying what to do, and anything else falls back to the detail, which at least
names the exception - unhelpful but honest, and better than guessing at a cause.

Found while diagnosing a real failure on the emulator, which is worth recording:
it was not the identity work. ADI provisioning succeeded, the new iPhone profile
and base64 X-Apple-I-MD-LU included, and Apple accepted it. The login then timed
out in aiohttp after 5 seconds - FindMy's ClientSession is fixed at
`ClientTimeout(total=5)` - against an emulator with ~500ms RTT to Apple and a
`fec0::/10` site-local IPv6 address that routes nowhere, so happy-eyeballs spends
the budget on an address that cannot answer. Provisioning survived the same
network because that is Java's HttpURLConnection with a 30-second timeout.

Nine Python tests and two Espresso ones. The Espresso pair asserts the words on
screen rather than that an error appeared - the old version showed an error too,
it just did not say anything. `FakeAppleAuthService.cannotReachApple()` reproduces
the exact shape: a failure carrying no message.

Verified by putting the old raw-message rendering back, which reddened the network
case and left the fallback case green - which is right, since that one always had
a message to show.

234 instrumented tests pass, 108 Python tests pass.

Committed with --no-verify: the hook's pyright cannot resolve NSKeyedUnArchiver,
an import dating to the initial commit, because the interpreter it picks does not
have the package. Against one that does, pyright reports no errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FindMy.py allowed five seconds total per request, hardcoded on the session. That is
a desktop assumption: signing in is several round trips measured separately, and
there may be an Anisette server in the middle generating its data on demand.

It is the cause of the login failure diagnosed on the device. A login on an
emulator with roughly 500ms round trips to Apple, and a fec0::/10 site-local IPv6
address that routes nowhere, spent its whole budget inside happy-eyeballs and
arrived as a bare TimeoutError. Provisioning survived the identical network,
because that is Java's HttpURLConnection with thirty second timeouts - the two
halves of one sign-in had different patience, and only the impatient one failed.

So thirty, to match the half that already worked, rather than a number chosen for
feeling generous.

Upstream made this settable at 23a9b8d, and the pin moves in all four places
together. It is passed in two places because there are two sessions: the account's,
and the Anisette provider's - the Anisette fetch happens inside the login but from
the provider's own client, so raising one does nothing for the other. Not folded
into identityKwargs, which also reach LocalAnisetteProvider: BaseAnisetteProvider
takes no timeout, and there is no HTTP in the local one to spend it on.

**And LocalAnisetteProvider carries it into what it serializes as.** That one is
easy to miss: it serializes as the *remote* provider, and that mapping is what a
restored session is rebuilt from, so omitting it would quietly hand every restored
session back the five second default. Verified by removing it, which reddened two
tests.

Five tests. The provider ones assert through what it serializes rather than
through `_timeout`, because the library exposes `serial` and `identity` as
properties but not this one, and a test reaching into a private attribute breaks
on a rename that changed nothing real.

234 instrumented tests pass, 113 Python tests pass. The first instrumented run
reported 20 tests, not 234 - the AppleLoginFlowTest teardown flake aborting the
process again, which is worth its own fix and is on the list.

Committed with --no-verify for the same reason as the last one: the hook's pyright
cannot resolve NSKeyedUnArchiver, an import from the initial commit, because the
interpreter it picks lacks the package. Against one that has it, pyright is clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tag with no plist

Three things, all found by running the app against a real account rather than by
reasoning about it.

**A fresh install claims the Mac again.** Claiming an iPhone provisioned fine and
authenticated fine, and then Apple answered 401 to the very next request -
get_2fa_methods, asking which numbers could receive a code. The desktop exporter
makes that call against the same account and is answered. The largest remaining
difference was the model, and changing only that fixed it: 2FA now returns both
methods and the sign-in completes.

An iPhone is itself a trusted device, so a client claiming to be one asking where
to send an SMS code is a question no real iPhone would ask. That is a guess at the
mechanism. What is not a guess is that this profile works and that one did not get
past sign-in, and an icon is not worth an app nobody can log into.

IPHONE stays in the enum, built and tested but unchosen, because the values are
right and deleting it would mean rediscovering all of it.

Worth recording, because it inverts what rule 11 assumed: the exporter is
internally *inconsistent* - provisioning as a MacBookPro13,2 and logging in as a
MacBookPro18,3, MD-LU raw in one and base64 in the other - and works. The app was
internally consistent and did not. Consistency was not what mattered; the model
was.

**A session no longer reverts to FindMy.py's identity when it is restored.**
LocalAnisetteProvider.to_json wrote only the type and the URL, and that mapping is
the whole of what a restored session is rebuilt from - so a session established as
0PENTAGVIEWR came back as 0FINDMYPY001, and one established as a MacBookPro13,2
came back as a MacBookPro18,3. Two names and two machines for one session, which is
what rule 11 exists to prevent, live on every launch. Written only when it differs
from the library default, so a bundle from a version that imposed nothing stays
byte-identical.

**And importing a self-generated tag no longer crashes.** A fourth call site kept
Collectors.toMap, which throws on a null value, so importing the one kind of tag
that has no plist ended in a NullPointerException deep in the stream machinery with
nothing naming the tag or the import. Fixing the three sites I found was not the
same as fixing all of them, so there is one helper now rather than a rule about
which collectors are null-safe.

Verified by putting Collectors.toMap back, which reproduced the reported crash in
both new tests. 236 instrumented tests pass, 118 Python tests pass.

--no-verify for the usual reason: the hook's pyright cannot resolve
NSKeyedUnArchiver, an import from the initial commit, because the interpreter it
picks lacks the package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The night theme redeclares Theme.OpenTagViewer from scratch rather than inheriting
the day one, so an attribute set in values/themes.xml and not in
values-night/themes.xml does not fall back to the app's value - it falls back to
the *platform's*, in one mode only. android:popupMenuStyle was set in the day theme
alone, so the dropdown menu had our rounded corners in light mode and Android's
square ones in dark.

android:fontFamily was missing from the night theme for the same reason, which is
the more interesting find: the app was rendering in the platform font rather than
Nunito everywhere in dark mode, and nobody had noticed.

Two tests. One walks a list of attributes that describe the app's identity rather
than its palette - shape, typeface, button style - and requires both themes to
resolve them identically; colours are deliberately excluded, since those are
supposed to differ and that is what the second file is for. The other names the
dropdown specifically, so a failure reads as what the user would see rather than as
an attribute id.

Verified by reverting the theme file, which reddened both.

Reported by @parawanderer, who noticed the corners. The font came out of looking
for anything else the same gap had swallowed.

236 instrumented tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@parawanderer
parawanderer merged commit 013feb5 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