Skip to content

Lock exported bundles by default, and tell the user the code - #150

Merged
parawanderer merged 7 commits into
mainfrom
feat/exporter-locks-bundles-by-default
Aug 23, 2026
Merged

Lock exported bundles by default, and tell the user the code#150
parawanderer merged 7 commits into
mainfrom
feat/exporter-locks-bundles-by-default

Conversation

@parawanderer

@parawanderer parawanderer commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Bundles now arrive locked, and the wizard tells you the code

Part of 1.1.0. Bumps app to versionCode 4 / versionName 1.1.0 and the exporter to
1.4.0 — a minor rather than a patch, because every recipient notices.

The wizard never locked anything, and that was deliberate

wizard.py hard-coded password=None, with a comment naming what would change it:

What unblocks this is an Android release containing zip4j, not a change here.

Before zip4j the app could not decrypt anything at all, so a locked bundle was a file nobody's
installed app could open — and the people worst affected were recipients, who did not choose
the exporter's version and could not fix it from their side.

App 1.1.0 reads them. So the blocker is retired rather than overridden.

This also ends a state worth naming: the CLI has locked by default since it existed
(cli.py:863), while the wizard never did. Two entry points, one repo, one VERSION, opposite
behaviour.

Release ordering is now a constraint, not a preference

Once this ships, a bundle from a 1.4.0 wizard cannot be opened by app 1.0.5 — that app has no
AES support at all and fails with a message about the zip rather than about a code. So the Android
1.1.0 release has to be published before the exporter 1.4.0 release, not merely in the same
milestone.

The Lock with a code checkbox exists for exactly that gap, and stays: a sender has no way to know
which version their recipient runs.

The code gets a window, because nothing stores it

The zip keeps only what AES needs to verify it. The log does not have it. The program forgets it.
So the one moment it exists in readable form has to be deliberate — modal, dismissed on purpose,
not behind a window that destroys itself.

It is selectable and has a Copy the code button, because twelve characters read off a screen
and typed into a chat window is precisely the case Crockford's alphabet exists to survive, and the
program can do it without the transcription error. It says the two things that decide whether the
bundle is safe: the code cannot be recovered, and it must travel separately from the file — a code
sent in the same message as the bundle is in the same backup as the bundle.

Also: a failed write no longer destroys the window. A full disk used to cost the whole selection
and another read of the account.

Testing

Twelve tests, every one mutation-checked:

Mutation Reddens
default flipped off test_the_checkbox_starts_ticked
dialog never shown test_the_code_is_shown_and_it_is_the_one_that_was_used
raw code shown instead of grouped test_thecodeIsOnScreen_grouped_for_reading
Copy appends "" test_copyPutsTheCodeOnTheClipboard

The first version of these tests was insufficient and is worth flagging. It mocked
_show_the_code out entirely, so it proved the wiring and left the only place the code is ever
displayed with no coverage at all — a dialog rendering the wrong string, or a Copy button copying
nothing, would have passed. Fixing it needed a seam: _show_the_code ends in wait_window, so a
test calling it hangs. Building the window is now separate from waiting on it.

Also worth saying: nothing asserted password=None before, so this default could have flipped
in either direction unnoticed.

Not in this PR

  • The app-side export does not exist yet — not partial, absent. When it lands it writes through
    the same opentagviewer_export, stamps via: OpenTagViewer.android:1.1.0, and needs this same
    code screen on Android. Design note for then: build_export returns in-memory entries, so Python
    builds them and zip4j writes the AES zip in Javapyzipper is not in Chaquopy's pip list
    and pulls a native crypto dependency.
  • README@parawanderer is doing that.

Not verified

The dialog is asserted, not eyeballed — no screenshot of the running wizard is in this PR. And
nothing here was tested end-to-end against a real Android import of a locked bundle written by
this build; the app's side of that is covered by its own tests against fixtures.

🤖 Generated with Claude Code


And the app became the third producer

Since the original description: the app writes bundles of its own. Export Tags had sat in the
selection menu, listed and disabled, with a comment reading "the XML disables it; this is where
to stop doing that"
. It stops.

Python owns the format, Java owns the container. build_export returns the files in memory;
its own sink needs pyzipper, which is not in Chaquopy's pip list and pulls a native crypto
dependency. zip4j is already here for reading locked bundles and writes them too — so neither
side grows a dependency, and there is still one implementation of the format across all three
producers. Bundles stamp via: OpenTagViewer.android:1.1.0.

Three failures, three answers

What happened What they get
a tag with no naming record a toast naming that tag — the importer inner-joins the records, so a bundle without one imports as nothing
the file will not write a toast about the disk
the builder cannot run the bug-report page, cause verbatim, export-specific wording

Only the third earns the page. It is worth nothing if it also appears for the first two.

Two bugs the tests found, both real

An export that failed crashed the app. The write was inside the subscribe onNext consumer,
and Rx cannot deliver an exception thrown there to the onError consumer — it goes to
RxJavaPlugins.onError and takes the process with it. So the failure handling asked for in review
did the exact opposite of its job. Found by driving the real flow; an earlier version of the test
started the report page itself and asserted the intent, which proves the page exists rather than
that the app reaches it.

Shot was silently writing black PNGs. UiAutomation.takeScreenshot() needs a display and
returns an all-black bitmap on the headless managed device, without failing. Five screens were
captured that way and every one was black. It now refuses to write a frame that is entirely one
colour, and gained decor-view capture that works on both kinds of device.

The round trip

WhatWeExportWeCanImportTest — nine cases, nothing faked: real Chaquopy builder, real AES
archive, real AppleZipImporterUtil including schema validation. It caught a partial accessory
record on its first run, which is exactly the class of bug that otherwise surfaces on somebody
else's phone days later.

Also pinned there: a code written down by hand — grouped, spaced, O for 0 and I for 1
still opens the archive. And reading with no code, or the wrong one, must fail; without those the
class would stay green if the writer quietly stopped encrypting.

Verified

Full instrumented suite 618 tests, 0 failed before this last commit; the round-trip class 9/9
and the export flow 4/4 after it. JVM, Python and --check (298 strings × 10 locales) all clean.

Still not verified

No real Apple account and no real AirTag were involved. The bundle is proved importable by this
app; it has not been imported by the desktop tooling, nor a bundle from this app opened on another
phone by hand.

The wizard has never password-protected an export. That was not an oversight:
wizard.py hard-coded password=None with a comment saying what would change it -
"an Android release containing zip4j, not a change here". Before that the app
could not decrypt anything at all, so a locked bundle was a file nobody's
installed app could open, and the people worst affected were recipients, who did
not choose the exporter's version and could not fix it from their side.

App 1.1.0 reads them, so the blocker is retired rather than overridden. The CLI
has locked by default since it existed, so this also ends the state where two
entry points in one repo, on one VERSION, did opposite things.

The checkbox stays for the case the blocker was about. Anyone on an app older
than 1.1.0 still cannot open a locked bundle, and a sender has no way to know
which version the recipient runs.

**The code needs a window of its own, because nothing stores it.** The zip keeps
only what AES needs to verify it; the log does not have it; the program forgets
it. So the one moment it exists in a readable form has to be deliberate: modal,
dismissed on purpose, and not behind a window that destroys itself. It is
selectable and copyable because twelve characters read off a screen and typed
into a chat window is exactly the case Crockford's alphabet exists to survive -
and the program can do it without the transcription error.

It says the two things that decide whether the bundle is safe: the code cannot be
recovered, and it has to travel separately from the file. A code sent in the same
message as the bundle is in the same backup as the bundle, and an exported
accessory cannot be withdrawn except by unpairing it.

_write_it also stops destroying the window when the write fails. A full disk or a
removable drive pulled mid-write used to cost the whole selection and another
read of the account.

Twelve tests, mutation-checked. The first four cover the wiring; the last four
drive the dialog itself, which the first version of this did not - it mocked
_show_the_code out entirely, leaving the only place the code is ever displayed
with no coverage at all. A dialog rendering the wrong string or a Copy button
copying nothing would have passed. That needed a seam: _show_the_code ends in
wait_window, so a test calling it hangs; building the window is now separate from
waiting on it.

Nothing asserted password=None either, so this default could have flipped in
either direction unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They have separate tags and separate workflows, which makes them look
independent. They are not: exporter 1.4.0 writes locked bundles and an app older
than 1.1.0 cannot decrypt one at all.

Publish the exporter first and every bundle written that day is unopenable by
whoever receives it - and the recipient is the one person in that transaction who
chose none of it and can fix none of it.

Nothing enforces this. release_version.py checks a tag against a version, not one
release against another, so it is a thing to remember, which is why it is written
down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer and others added 4 commits August 23, 2026 17:41
The third producer of this format, beside the desktop wizard and its CLI. It
writes through the same opentagviewer_export.build_export they do and stamps
via: OpenTagViewer.android:<versionName>, so a zip still says which of the three
built it - which is the whole reason that package is shared rather than copied.

**Python owns the format, Java owns the container.** build_export returns the
files in memory; the package's own sink needs pyzipper for an encrypted archive,
which is not in Chaquopy's pip list and pulls a native crypto dependency. zip4j
is already here for reading locked bundles and writes them too, so neither side
grows a dependency for the other.

Bytes cross as base64 inside JSON. A plist is not UTF-8 and an OwnedBeacons
record carries the accessory's private key, so anything lossy in the crossing
produces a bundle that imports cleanly and then locates nothing - discovered days
later, by the recipient, after the sender has deleted their copy.

AES-256 under the WinZip scheme, matching zipsink.py and what the importer
already opens. Not ZipCrypto: broken since the nineties, and a bundle protected
by it would be protected in name only while passing every other test here. The
listing is not encrypted, only the entries - a property of the format worth
knowing before treating the file as opaque.

**An unusable key alignment record no longer costs the whole export.** The format
layer refuses one and says what to do about it - "pass no alignment record at all
rather than an unreadable one: the import is then slow, not broken" - so the
bridge acts on that rather than relaying it, retries without them, and reports a
warning. Refusing everything because an optional record is malformed trades
something that works badly for nothing at all. A genuinely broken accessory is
still refused, rather than becoming a silent partial export.

BundlePasscode gains generate() and format(). It only ever read codes before;
SecureRandom because the zip format fixes PBKDF2 at 1000 iterations, so the code
itself is the whole of the security.

The builder sits behind an interface for the usual reason and one more: an export
that throws leaves somebody with no file and no explanation, having just decided
to share the keys to their tags. That path needs driving in a test, and breaking
the interpreter on demand is not a thing.

Eleven bridge tests on plain CPython, eleven container tests on the JVM - nothing
here needs a device. Mutation-checked: writing ZipCrypto instead of AES reddens
exactly the test that names it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Export Tags has sat in the selection menu, listed and disabled, since the menu
existed - with a comment saying "the XML disables it; this is where to stop doing
that". The shared export package is on the phone now, so it stops.

Pick tags, choose where the file goes, and read the code that opens it. Sharing
rather than backing up: an owner signed into their own account does not need a
bundle, and this is what a bundle is actually for. The act is irreversible -
exported key material cannot be withdrawn, and the only way to revoke it is to
unpair the accessory.

**Three failures, three answers, and the difference is the whole point.** A tag
with no naming record is named in a toast: the importer inner-joins the two
records and drops what it cannot pair, so exporting one produces a bundle that
imports successfully and contains nothing - the sender is told it worked and the
recipient finds out it did not. A file that will not write is the disk, and says
so. Anything else is the app failing at something it should manage, and only that
reaches the report page, which is worth nothing if it also turns up for the first
two.

**The write is a map, not something done inside subscribe, and that was a crash.**
Rx cannot deliver an exception thrown in the onNext consumer to the onError
consumer, so it goes to RxJavaPlugins.onError and takes the process with it. The
first version had the write in the consumer: an export that failed killed the app
instead of showing anything. Found by driving the real flow - the earlier test
started the report page itself and asserted the intent, which proves the page
exists rather than that the app reaches it.

describe(Throwable) unwraps to the root cause for the same reason: Rx wraps what
a map throws, so the page would have said "RuntimeException" and buried the
sentence a reporter needs. It also moved to ErrorReportActivity, since two
screens writing that string differently makes two reports of one bug look like
two bugs.

source_user is the device model, never the Apple ID. The shared package asks for
a label and is right to: that string travels inside a file going to another
person and often onward from there.

Shot no longer writes a screenshot that is entirely one colour.
UiAutomation.takeScreenshot() needs a display and returns black on the headless
managed device, silently - five screens were captured that way and every one was
black, with nothing in the run saying so. A missing file is noticed; a black one
is mistaken for a screen that renders nothing. It gained decor-view capture,
which works on both kinds of device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two halves had never met. The container test proves zip4j round-trips bytes;
the bridge tests prove Python builds the right files. Both pass while the format
is wrong, because neither had ever handed its output to the thing that reads it -
and the format is where a mistake hides: a directory named in the singular, a
naming record filed under the wrong identifier, a manifest that fails schema
validation.

Nothing is faked. The real Chaquopy builder runs, zip4j writes a real AES
archive, and AppleZipImporterUtil opens it exactly as it opens a bundle from the
desktop exporter.

It found something on its first run - a fixture with a privateKey and nothing
else was refused with "missing sharedSecret ... the accessory would fail
conversion on the recipient's phone rather than here". A fixture bug rather than
a product one, but it is precisely the failure this exists for, demonstrated: a
partial record writes cleanly and fails at the other end, and nothing else in the
stack says a word about it.

Nine cases. The two worth naming: the naming record is asserted separately from
the accessory count, because the importer inner-joins them and a misfiled one is
dropped silently while the import still reports success with nothing in it; and a
code written down by hand - grouped, spaced, with O for 0 and I for 1 - still
opens the archive, which proves Crockford folding survives to the bytes rather
than only to a string comparison.

Reading it with no code, and with the wrong one, both have to fail. Without those
the whole class would stay green if BundleZipWriter quietly stopped encrypting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Information screen listed every producer on the install, which was both
cramped - it landed between the version and "Free, Open-Source Software by:",
reading as part of the same sentence - and misleading. A list can hold tags from
several exports and from an Apple account at once, so one line summarising all of
them answers a question nobody asked.

The tag's own page already answers it precisely, so the template sends people
there instead, and says what the three outcomes mean:

- an exporter and version, which is the answer
- "Not recorded - an older exporter", which dates the bundle and is useful
- no such row at all, with Source reading "Read from your Apple account" - that
  tag never came from a file, so no exporter was involved

That third case is the one worth spelling out. An account tag has no Import row,
so the whole block is hidden rather than empty, and somebody looking for a
version they were asked for finds nothing and has no idea whether that is an
answer or a fault.

getDistinctProducers goes with it, along with the two tests that covered the
screen and the query. The error page keeps its line - it is context for a failure
that just happened rather than a summary of the install - but it now says "most
recently imported from", because it reads getMostRecent and should not imply it
speaks for every tag.

Also: 32dp under the device info scroll area, with clipToPadding=false. Vendor Id
was the last row and sat level with the gesture bar, which reads as a screen cut
off mid-list. Inside the scrolling area, so nothing above it is lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@parawanderer parawanderer added this to the App version 1.1.0 milestone Aug 23, 2026
@parawanderer
parawanderer merged commit 6305ae6 into main Aug 23, 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