Skip to content

Say which half of an import failed, and give the rest a log worth sending - #149

Merged
parawanderer merged 5 commits into
mainfrom
feat/logs-that-answer-the-questions
Aug 23, 2026
Merged

Say which half of an import failed, and give the rest a log worth sending#149
parawanderer merged 5 commits into
mainfrom
feat/logs-that-answer-the-questions

Conversation

@parawanderer

Copy link
Copy Markdown
Owner

The app told four reporters their import failed. It hadn't.

Picking a zip started one Rx chain that parsed the file, committed the tags to the database,
and then went to Apple for their locations. One error handler covered all three, so a network
timeout, an expired session or a bad Anisette server — none of which are consulted while reading
a zip — all reported:

Error occurred while importing new devices. Try to restart the app and retry.

…for an import that had already succeeded and whose tags were visible in the device list.

#19 and
#26 are 34 comments of people working
that out for themselves. Three of them fixed their "import error" by changing their Anisette
server
, which is the proof: nothing about Anisette is touched while parsing a file.

Three things were wrong, in order of cost:

  1. It named the wrong phase. The import was done and committed.
  2. The advice could not work. Restarting does not re-run a failed fetch, and the import it
    suggested retrying had succeeded.
  3. It threw away the diagnosis. The real exception went to Log.e and nowhere a user could
    see it — which is why none of those reports can be attributed to a cause.

What changed

The chain is split at the commit point. Reading the zip and storing the tags is one
operation; asking Apple where they are is another, started only once the first has succeeded. A
phase marker would have fixed the message; splitting makes the wrong message impossible to write,
because there is no longer one handler that could say it.

Each half now says its own thing:

The zip could not be read a named reason where there is one, and otherwise a page that can be reported
The tags arrived, the locations did not "Your tags are imported" — they are on the phone and stay there — plus the real exception, Anisette named as the likely cause, and the setting that fixes it

No bug report on the fetch side, deliberately. That half fails when somebody's server is down
or a phone is on a train, and it retries by itself every minute. Inviting a report each time
would fill the tracker with weather. The bug page belongs to the half that reads the zip.

A page for a failure nobody can act on

UnhandledProtocolError and anything reaching REASON_UNKNOWN now land on a page carrying the
three things a report needs and a person otherwise has to hunt for: which build this is, which
exporter made their bundle, and the failure verbatim. Never for a rejected passcode, an account
with no tags, a network that is down, or a session wanting a code — each has advice this page
would be strictly worse than.

Get the log asks which of two things is wanted, because they are not the same thing: copy it
for pasting into the issue form's log box, or save it through the document picker to attach. It
shipped as a share sheet, which serves neither — with text and no stream, Drive and Files do not
appear as targets at all.

Both routes out of the app now clean the log

Settings → Export Logs wrote a raw logcat while the error page's copy went through the
redactor. Two answers from one app to "is my Apple ID in this file". Same rule on both now, and
the same refusal: if redaction cannot run, nothing is written rather than a raw log handed to
somebody on their way to attaching it somewhere public.

redact.py gains four rules for things only the app produces — coordinates (labelled and as bare
pairs, since the app writes %.4f,%.4f), reverse-geocoded place names, bundle passcodes, and the
phone number main.py prints when it lists second-factor methods. They live with the exporter's
rules rather than in a second redactor, because two sets of rules mean two answers and only one
of them would get maintained.

Which exporter made a bundle, in both places it is asked

The issue template asks, and the only place the answer lived was inside the export zip — a file
holding the private keys to somebody's tags, which the same template tells them in bold not to
open.

  • Information screen lists every distinct producer, newest-used first. Not the most recent
    one: importing twice is ordinary, and naming one producer implies it accounts for every tag on
    the phone.
  • A tag's own page says which exporter made that tag. When a report is about one tag out of
    twelve, the aggregate cannot tell you that. The row was nearly free — the page already read the
    same Import for its "Exported by" and "Exported at" rows.

Testing

JVM WhichHalfOfAnImportFailedTest, DescribingTheBuildInALogTest, TheIssueLinkPointsSomewhereRealTest
Python 42 in test_redact.py, including the false-positive guards for the coordinate rule
Instrumented the error page, both import halves, the dialog's two dismissal routes, the DAO, the device page, the Information screen

Every new assertion was mutation-checked. Two were decorations and are fixed:

  • thesharedLogIsTheRedactedOne asserted only that a chooser opened — its own comment claimed
    otherwise. It would have stayed green while shipping an unredacted log, which is the one
    outcome here that cannot be taken back. It now asserts on the payload, and bypassing redaction
    reddens exactly the three tests that should go red.
  • A coordinate false-positive guard used floats that were not adjacent, so the rule would never
    have matched them however loose it got — loosening \d{3,} to \d+ left all 41 green. Replaced
    with a case that sits on the boundary.

ImportDao gains a query and no schema change, so there is no migration.

Not verified

The Apple-facing paths are unchanged by this, but nothing here was exercised against a real
account or a real failing Anisette server — the fetch-failure dialog was driven with a fabricated
exception, not a live outage. The claim being made is about which message appears for which
failure, not about the failures themselves.

🤖 Generated with Claude Code

parawanderer and others added 5 commits August 23, 2026 17:07
UnhandledProtocolError and anything reaching REASON_UNKNOWN had no screen of
their own. They arrived at a toast saying to restart the app, which cannot help
and asks somebody to repeat what just failed.

The page carries the three things a report needs and a person otherwise has to
hunt for: which build this is, which exporter made their bundle, and the failure
verbatim. The last is why somebody would open an export zip - a file holding the
private keys to their tags - to read a version line out of it.

Shown only when the app cannot name the cause. Never for a rejected passcode, an
account with no tags, a network that is down, or a session wanting a code: each
of those has a screen saying what to do, and this one would be strictly worse
than the advice they already give. A page that appears for ordinary mistakes is
one people learn to dismiss, and then it is worth nothing on the day it is right.

Get the log asks which of two things is wanted, because they are not the same:
copy it for pasting into the issue form's log box, or save it through the
document picker to attach. It cannot hand over a log the redactor refused - null
means withhold, never fall back to the raw one, because an Apple ID on a public
issue cannot be un-posted and this page is reached because something already
broke.

All ten locales' strings for this branch land here, including ones later commits
use: they share ten files, and splitting them by hunk would risk a locale going
missing for the sake of a tidier history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Picking a zip started one Rx chain that parsed the file, committed the tags to
the database, and then went to Apple for their locations. One error handler
covered all three, so a network timeout, an expired session or a bad Anisette
server - none of which are consulted while reading a zip - all reported "Error
occurred while importing new devices. Try to restart the app and retry", for an
import that had already succeeded and whose tags were visible in the device list.

Issues #19 and #26 are 34 comments of people working that out for themselves.
Three of them fixed their "import error" by changing their Anisette server, which
is the proof: nothing about Anisette is touched while parsing a file.

Three things were wrong, in order of cost. It named the wrong phase. Its advice
could not work - restarting does not re-run a failed fetch, and the import it
suggested retrying had succeeded. And it discarded the diagnosis: the real
exception went to Log.e and nowhere a user could see, which is why none of those
reports can be attributed to a cause.

So the chain is split at the commit point rather than marked. A phase marker
would fix the message; splitting makes the wrong message impossible to write,
because there is no longer one handler that could say it.

The fetch half gets a dialog saying the tags are on the phone and stay there, the
exception verbatim, and Anisette named - the answer three reporters reached
unaided. No bug report offered there, deliberately: it fails when somebody else's
server is down or a phone is on a train, it retries by itself every minute, and
asking for a report about weather fills a tracker with reports nobody can act on.

Completable.defer, because the fetch is now started from the main thread and
subscribeOn moves the subscription rather than the construction. combine() streams
every beacon into two maps and plistFallbacks walks them again, both evaluated as
arguments; while this hung off the import chain it inherited that chain's IO
thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four rules for things the exporter never sees, so none of them will fire on a
wizard log. They live here rather than in a second redactor because two sets of
rules mean two answers to "is my Apple ID in this file", and only one of them
would get maintained.

(The Export Logs caller that needed this went in with the MapsActivity change in
the previous commit: it wrote a raw logcat while the error page's copy went
through the redactor, which was two answers from one app. Both now refuse rather
than fall back - if redaction cannot run, nothing is written, because a raw log
cannot be un-posted and whoever exported one is on their way to attaching it.)

Coordinates get two rules, labelled and as a bare pair, because the app writes
both: String.format("%.4f,%.4f", ...) for a geocoding cache key and point=(lat,
lon) for a tapped marker. Four decimal places is about eleven metres - somebody's
home rather than their town, and the most personal thing this app handles.

Place names are labelled-only. "221B Baker Street, London NW1 6XE" has no shape a
regex can find, and a pattern loose enough to catch it would redact half the log.
Nothing logs one today; the rule is here so the day somebody adds Log.d(TAG,
"resolved to " + address), it is already covered.

Passcodes are the grouped form only. The bare twelve characters of Crockford
base32 are indistinguishable from a hash fragment, and the grouped form is what a
person copies and pastes anyway.

The phone number is main.py:142, which prints Apple's SMS option verbatim when it
lists second-factor methods. Nothing was stripping it.

The coordinate rule's false-positive guard was mutation-checked and had to be
rewritten: it used floats that were not adjacent, so the rule would never have
matched them however loose it got - loosening \d{3,} to \d+ left all 41 tests
green. The replacement sits on the boundary and goes red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The issue template asks, and the only place the answer lived was inside the
export zip - a file holding the private keys to somebody's tags, which the same
template tells them in bold not to open. Asking a question whose answer is in a
file you have told people not to open is asking them to ignore you.

The Information screen lists every distinct producer, newest-used first. Not the
most recent one: importing twice is ordinary - a second Mac, a re-export after
buying a tag, an old bundle beside a current one - and naming one producer reads
as though it accounts for every tag on the phone. A report saying 1.3.0 when half
the tags came out of 1.1.0 sends whoever reads it looking in the wrong place.

GROUP BY rather than SELECT DISTINCT, because the ordering is by something not in
the result: SQLite rejects an ORDER BY on a column a DISTINCT query does not
select, and MAX(imported_at) per producer is the sort that means "most recently
used".

A tag's own page says which exporter made that tag, which the aggregate cannot.
When a report is about one tag out of twelve, the exporter that made that one is
the fact that decides what to expect of it. Nearly free: the page already read the
same Import row for Exported by and Exported at.

Both say so rather than hiding when there is nothing recorded. An export predating
via: is itself information - it dates the bundle - and a missing row reads as a gap
in the screen.

A query and no schema change, so there is no migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The issue template said "this is a raw Android log and nothing is removed from
it", which stopped being true this branch, and told people to press a button
called Export logs on the error page, which is now called Get the log. It also
asked them to audit a log line by line, which is no longer the deal.

The rest is the afternoon this cost. A managed-device run that is killed rather
than finished never gives its slot back, and AGP counts them in
~/.android/avd/gradle-managed/active_gradle_devices. Mine reached MDLockCount 4,
after which every run waited for a slot nothing would release - booting no
emulator and printing nothing, which for ten minutes is indistinguishable from a
device starting slowly. Three runs were abandoned diagnosing it as port
contention. adb devices said so the whole time by listing no managed AVD.

Documented symptom-first, with the two hangs that look identical separated by
what they leave behind: no test ever reporting is the lock, tests stopping
mid-suite is the device dying under them.

And a skill, because the reporting was worse than the bug. Three hand-written
monitors in one afternoon each matched nothing - one required (0 skipped) against
a run with five skipped, one required a space before [testEmulator] that is not
there, one truncated before the verdict with head. All three exited 0, and
silence from a monitor is indistinguishable from silence from a healthy run, so a
red suite read as green until the log was opened by hand.

watch_tests.py owns the patterns, with --once to prove they match before anything
depends on them. Writing it caught two more of the same: ^BUILD without
re.MULTILINE reported "not yet" on a finished run, and summing every result file
on disk reported a 22-test run as 74 because AGP keeps connected/ and
managedDevice/ side by side and clears neither. It now scopes to the newest run's
directory and always prints the results' age, because a verdict that is quietly
ten minutes old is the same bug wearing a coat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@parawanderer
parawanderer merged commit 079bbc1 into main Aug 23, 2026
8 checks passed
@parawanderer
parawanderer deleted the feat/logs-that-answer-the-questions branch August 23, 2026 15:28
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