Say why a sign-in failed, and add the seam a map test needs - #114
Merged
Conversation
…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>
parawanderer
temporarily deployed
to
Android Build
August 19, 2026 16:54 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 19, 2026 16:54 — with
GitHub Actions
Inactive
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>
parawanderer
temporarily deployed
to
Android Build
August 19, 2026 17:06 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 19, 2026 17:06 — with
GitHub Actions
Inactive
…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>
parawanderer
temporarily deployed
to
Android Build
August 19, 2026 17:38 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 19, 2026 17:38 — with
GitHub Actions
Inactive
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
temporarily deployed
to
Android Build
August 19, 2026 17:43 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 19, 2026 17:43 — with
GitHub Actions
Inactive
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.
"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 emptystring. 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.
describeLoginFailurealso guarantees a non-empty detail by falling back to the type name, sincean 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 recognisedreason 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()reproducesthe 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:
A fresh install on the new path — iPhone profile, base64
X-Apple-I-MD-LU— provisioning withApple and being accepted. Then the login timed out in
aiohttp.Three things compounding, none of them ours:
ClientSession(timeout=ClientTimeout(total=5))fec0::/10— site-local, routes nowhere, so happy-eyeballs burns the budget on itElapsed in the log was 5.75s. Provisioning survived the same network because that is Java's
HttpURLConnectionwith the 30-second timeouts — same network, six times the patience. And theCLI works because it runs on the host, not inside the emulator's NAT.
Also here: a seam for a fake map provider
MapProviderFactorygains a test-only override, plus aFakeMapProviderthat 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
MapsActivitycan starton
aosp-atdestablished that Play Services is not the blocker — the screen reaches mapinitialisation 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. Astored 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