Skip to content

Produce Anisette on the device instead of trusting a server - #62

Merged
parawanderer merged 30 commits into
mainfrom
feat/native-anisette-poc
Aug 11, 2026
Merged

parawanderer merged 30 commits into
mainfrom
feat/native-anisette-poc

Conversation

@parawanderer

@parawanderer parawanderer commented Aug 11, 2026 •

Copy link
Copy Markdown
Owner

Closes #34.

image

Signing in to Apple needs Anisette data. Until now this app got it from a public Anisette server: an operator sees that traffic, and sign-in stops working when their machine does. It does not need to be that way — Apple's ADI libraries are native Android code and run perfectly well in this process.

They now do. The app fetches Apple's own libraries, verifies them against recorded hashes, provisions a machine identity with Apple once, and produces Anisette itself. A remote server remains as a fallback and can still be chosen.

What this changes for people already using the app

Nothing, unless they choose it. A session is bound to the machine identity that established it, so moving an existing login to local Anisette forces a re-authentication. Anyone updating therefore stays on their server, and is offered the switch once, with the cost stated up front. anisetteMode is deliberately nullable for this reason — see UserSettings.resolveAnisetteMode.

New sign-ins get local Anisette by default.

How it works

  • 11 obfuscated ADI entry points, resolved by dlopen/dlsym from C++ — the symbol names change between Apple Music builds, so static JNI bindings could never work
  • Two generated stub libraries stand in for the five Apple ships that nothing actually imports from, cutting the download from 12.6 MB to ~2.9 MB. No ELF loader is needed; the bionic linker resolves DT_NEEDED by SONAME
  • gsa.apple.com chains to a root Android does not ship. The reference implementation disables peer verification; this pins that one root for that one domain instead
  • Symbol lists are checked in as text and asserted in CI, with a weekly job that opens an issue if Apple replaces the APK

Measured on a Galaxy S25: 2.9 MB downloaded, libraries loaded in 11 ms, and once set up it needs no network at all.

Testing

84 instrumented tests, plus 5 that talk to Apple for real and are skipped unless opted into. Espresso now works here — that needed the Gradle managed device (a guest window only has focus while the emulator's own window has focus on the host), a host activity in the debug source set, and animations disabled. AGENTS.md and CONTRIBUTING.md cover it, including how to replay a flow in slow motion for a human.

Two bugs were found by writing these rather than by review:

  • The sign-in screen had no scroll container. Revealing the server field pushes the button below it off a Pixel 6 — on the one screen where that is unrecoverable, since Settings is behind the sign-in. It passed on a tall emulator and failed on the managed device.
  • The login button called requireNonNull on a server URL that can now legitimately be null, because a sign-in that needs no server never visits that step.

Note for review

The instrumented CI job now runs the same managed device developers run locally, replacing reactivecircus/android-emulator-runner. This PR is the first time that has ever run on a runner — if anything here is going to fail, it is that.

🤖 Generated with Claude Code

parawanderer and others added 21 commits August 10, 2026 09:56
Every login currently goes through a public Anisette server. Those servers are
unreliable, and they see traffic that has no reason to leave the device. Apple's
ADI libraries are native Android code, so in principle the app can run them
itself - but nobody appears to have done that on Android, so the risk was
unknown rather than merely large.

The open question was W^X: apps targeting API 29+ are not meant to execute code
from writable storage, and if Android refused to load these libraries the only
way forward would have been mapping and relocating the ELF by hand, which is
weeks of work. That question is now answered, and the answer is no restriction:

    avc: granted { execute } for path=".../files/adi-poc/x86_64/libc++_shared.so"
         scontext=u:r:untrusted_app:s0 tcontext=u:object_r:app_data_file:s0

All eleven libraries dlopen out of filesDir, all eleven ADI entry points
resolve, and ADILoadLibraryWithPath returns 0 - with libCoreADI.so opened by
Apple's own code rather than by us. So no hand-written loader is needed.

Notes on the shape of this:

- The libraries are never redistributed. They are fetched at runtime from
  Apple's own CDN, and HTTP range requests over the APK's central directory
  take ~11 MB rather than the full 142 MB.
- dlopen follows DT_NEEDED, so it is eleven libraries rather than the two that
  anisette-v3-server keeps: Apple ships its own CoreFoundation, libdispatch,
  ICU, curl and libxml2, and ADI sits on top of them. Loading bottom-up is what
  makes that work, since bionic resolves DT_NEEDED against already-loaded
  SONAMEs.
- The wrapper is C++ because it has to be. The entry points are obfuscated
  names that change between APK builds, so they can only be reached by dlsym and
  called through function pointers - Java's native keyword cannot bind to them.
- This adds the NDK as a build requirement. Pinned to r27, which is also the
  first NDK that aligns shared libraries to 16 KB pages by default.

The tests download from Apple's CDN, so they are skipped unless asked for with
-Pandroid.testInstrumentationRunnerArguments.adiPoc=true.

Verified on the managed device (API 34, x86_64). Not yet verified on arm64, and
provisioning is not implemented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…calls

Running Anisette in-app means dlopen'ing Apple's libstoreservicescore.so, and
dlopen follows DT_NEEDED. That dragged in eleven libraries and 28 MB on disk:
Apple ship their own CoreFoundation, libdispatch, ICU, curl and libxml2, and ADI
sits on top of them.

Measuring which symbols are actually imported shows the tail is unreachable.
libstoreservicescore imports from only three libraries - libc++_shared (141
symbols), libmediaplatform (125) and libCoreFoundation (93). Nothing references
ICU, curl, libxml2, libdispatch or BlocksRuntime; they are in the closure purely
because CoreFoundation and mediaplatform depend on them, and ICU alone is
14.6 MB.

bionic resolves DT_NEEDED by SONAME against libraries that are already loaded,
and does not check who built them. So the app now ships generated stubs carrying
those two SONAMEs, and the 20 MB behind them is never fetched:

    before   12 libraries   12.6 MB downloaded   ~28 MB on disk
    after     3 libraries    ~2.9 MB downloaded  ~5.2 MB on disk

Verified on the managed device: all eleven ADI entry points still resolve and
ADILoadLibraryWithPath still returns 0.

The one thing that did get called is worth knowing about. Of 218 stubbed
symbols, exactly one was reached during initialisation:

    mediaplatform::WorkQueue::makeWorkQueue(std::string const&, WorkQueueType)

Our stub returned NULL and ADI carried on, but provisioning is where it would
actually schedule work on that queue, so stubbing is proven for initialisation
and unproven beyond it. That is exactly why the stubs log and count rather than
abort - a silent wrong answer is worse than a crash, and Apple can start
depending on any of these without telling anyone.

Supporting pieces:

- scripts/update_adi_stub_symbols.py regenerates the lists from Apple's current
  APK, and --check reports drift for CI. The symbol lists are checked in as
  text and the C is generated at build time, so no binary of Apple's is stored
  here.
- AdiFunction collects the eleven entry points, their obfuscated symbols and
  their signatures in one enum, so a failure reads "ADIProvisioningStart
  (rsegvyrt87)" rather than a hex-soup string, and there is one place to edit
  when Apple re-obfuscates.
- adi-libraries.json records the APK version, ETag and a SHA-256 per library.
  Apple serve one "latest" URL with no versioned variant, so the build cannot be
  pinned by asking for an old one - but what we accept can be, and the app can
  refuse anything it cannot vouch for and fall back to a remote Anisette server.
  It also makes an untrusted mirror safe to use later, if Apple's URL ever moves.
- A weekly workflow opens an issue when any of that drifts. Deliberately not a
  PR check: Apple re-obfuscating their APK is not a reason to block an unrelated
  pull request, and it depends on an external service.

Apple's APK has not changed since April 2025, so none of this is expected to
fire often - it is here so that when it does, the failure is legible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This is the last unknown in running Anisette in-app. Loading Apple's ADI
libraries and initialising them were already proven; whether Apple would
actually provision a machine we invented was not. It does:

    provisioning as 3ED7500C-29FA-4A26-99A2-47D8BD863E01 (ADI id bb1037520408cb27)
    ADI initialised, persisting to .../files/adi-provisioning-state
    provisioned: session 1458087437 completed
    already provisioned          <- second call correctly a no-op

Apple accepted an entirely invented identity, ADI took the persistent token and
trust key, and ADIGetLoginCode then returned 0. Verified end to end against
Apple's live servers by AdiProvisioningTest. No Apple account is involved: dsId
is -2, anonymous provisioning.

Three things were not obvious from the reference implementation.

**The identity lengths are checked.** The values are invented, but their sizes
are not free. Provision's `rndGen.take(2)` takes two uints, not two bytes - so
the ADI identifier is 8 bytes as 16 hex characters, and the local user UUID is
32 bytes as 64. Getting that wrong returns -45001, invalid parameters.

**gsa.apple.com does not validate against Android's trust store.** It chains to
the original "Apple Root CA", which Android does not ship:

    SSLHandshakeException: Unacceptable certificate: CN=Apple Root CA

Provision solves this with sslSetVerifyPeer(false). This does not. Turning off
certificate verification in an authentication flow, inside an app, on a user's
network, would make the exchange interceptable by anything on the path - a
server operator can make that trade for themselves, but we cannot make it for
somebody else. The actual problem is narrow: one missing root. So that one root
is added, for that one domain, alongside the system anchors - which is stricter
than the default rather than looser. The certificate is Apple's own, published
at https://www.apple.com/appleca/, verified against its published SHA-256, and
stored as PEM text so it is reviewable in a diff rather than an opaque .cer.

**The stubbing bet holds where it mattered.** makeWorkQueue is called from a
static constructor and our stub returns NULL - and provisioning completes
regardless. So it is now recorded in libmediaplatform.expected and logs at INFO.
Before this, a completely successful run logged "Do not trust this session" at
ERROR, which is how alarms get ignored. Anything not on that list still raises
it. Entries require evidence: a passing end-to-end run with the call observed.

Also here:

- The package is anisette rather than poc, and NativeAdiProbe is NativeAdi. It
  stopped being a proof of concept when it started working.
- AdiError names all 21 known ADI codes, because -45001 is not a diagnosis.
- AdiLibrary resolves all eleven entry points up front and refuses to start if
  any are missing, rather than discovering it halfway through a session.
- SimplePlist reads Apple's responses with XmlPullParser. The app can already
  parse plists through Python, but that exists for reading exported keychain
  data and starting an interpreter for three dictionaries of strings is the
  wrong shape.

Still to do: ADIOTPRequest, which runs per login rather than once; the Python
provider that hands FindMy.py the headers; and the fallback to a remote server
when any of this is unavailable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With the machine provisioned, ADIOTPRequest yields the headers a login needs
without asking anyone. That is the point of the whole exercise: today every
login goes to a public Anisette server, which means an operator sees the
traffic and the app stops working when their server does.

    X-Apple-I-MD:        AAAABQAAABBSUIpSHa7cBJnWyxeDD4UkAAAABA==
    X-Apple-I-MD-M:      fxB7CSVYqLXLJ+vWLeubVOG64YOcI1H4+Eav8164u10...
    X-Apple-I-MD-RINFO:  17106176
    X-Apple-I-MD-LU:     CA81A6CB3F3E34B642778EF496CA89843C406D1341C0...
    X-Mme-Device-Id:     06B254E3-DBDA-4D85-8022-C277E6BDC9CB
    ...

The shape matches anisette-v3-server's /v3/get_headers exactly, which is what
FindMy.py already consumes - so the Python side can treat local and remote as
the same thing, and falling back costs nothing but a different source of the
same strings.

Two things measured rather than assumed, after guessing wrong twice:

**The password is not one-time per call.** Repeated requests return the same
value for a while and then it rotates - observed changing within 16 seconds,
sampling every two. That is enough to establish rotation on that order and not
enough to claim a period, so the test samples and logs rather than asserting one
I made up. Practically: reuse within a login is fine, produce fresh per login.

**ADIOTPRequest returns the machine identifier first, then the password.** Both
are ubyte**, so wiring them backwards compiles, runs, and produces headers Apple
rejects. Recorded on AdiFunction.OTP_REQUEST so nobody has to rediscover it.

The test asserts the two properties that catch a plausible-looking failure: that
the machine identifier is stable across requests, and that the headers are all
present and non-empty. A constant password would otherwise look like success.

Also worth knowing for later: ADI has a dedicated time error code (-45036), so a
device with a badly wrong clock is a failure mode Apple anticipated. It would
present as a rejected login with nothing visibly wrong, which is the kind of
thing that deserves a real error message when the UI exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wires the ADI work into the app. Logins and refreshes now produce Anisette on
this device, and use a public Anisette server only when they cannot - which
inverts the current position, where every login is relayed through a stranger's
machine that also takes the app down when it goes offline.

The fallback is not decoration. Apple can change their libraries at any time,
the first run needs network, and this is an optimisation over something that
already worked - so every failure path degrades to the old behaviour rather than
failing a login. LocalAnisette catches broadly on purpose: a login is not the
place to discover a new exception type.

**Restore matters as much as login.** A saved account carries the serialized
remote provider, so without swapping it on restore, local Anisette would apply
only to the single login where the account was created and every later session
would go back to the server. getAccount now swaps it.

**Accounts are still serialized as "aniRemote".** Whether Anisette came from
this device or a server is a transport detail, not part of the account - the ADI
state lives in app storage. Writing the remote URL keeps exported logins
restorable anywhere, including by builds that have never heard of this. Verified
against FindMy on the desktop: the provider swaps, the fallback URL carries
over, and to_json still emits aniRemote.

**But switching between them is not free, and is no longer silent.** Apple ties
trust to the machine identity presented at login, and local and remote Anisette
present different ones. A session established here and later continued through a
server is, from Apple's side, the same account arriving from a different
machine, which may invalidate device trust. So the app records which kind
established the session - on every login, so it cannot go stale - and says
plainly when that changes:

    WARNING: this session was established with local Anisette and is now
    continuing against a remote server. Apple sees a different machine, so
    signing in again may be required.

It still falls back, because degrading to maybe-reauth beats not working. What
was dangerous was doing it silently and leaving somebody in an unexplained 2FA
loop. Note this exposure already existed: a saved account points at a URL, and
that server's identity changes if it is rebuilt - which is presumably why this
app already suggests trying a different Anisette server after failed 2FA codes.

**Nothing is loaded before it is verified.** AdiLibraryManifest checks each
download against the SHA-256 in assets/adi-libraries.json, generated by the same
script and from the same APK as the stub symbol lists, so the two can never
describe different builds.

Two things this surfaced that were not planned:

**The instrumented suite was failing on this branch.** An Assume in @BeforeClass
skips the whole class, and AndroidJUnitRunner reports "expected 40 tests,
received 33" as a failed run. Each test now opts out for itself. A class-level
assumption is not a skip, it is a missing test.

**NativeAdiLoadTest cannot coexist with the stubs, and is removed.** Our
libCoreFoundation.so ships in the APK, so once loaded it owns that SONAME for
the process - and it exports only what libstoreservicescore imports, not what
Apple's real libmediaplatform needs. The unstubbed path now fails with "cannot
locate symbol CFCopyDescription", correctly. The stubbed test covers what it
proved, and is the path we ship.

Also:

- The opt-in flag is anisetteLiveTests, not adiPoc. It is gated because these
  tests reach Apple's servers and provision a new machine identity on every run,
  not because the work is experimental.
- A test asserts the exact set of downloaded libraries. Stubbing is what keeps
  this at ~2.9 MB rather than ~12.6 MB, and a regression would be invisible -
  every other test would still pass, just after downloading five times as much.
- update_adi_stub_symbols.py now fails when libstoreservicescore imports from a
  library that is neither stubbed nor downloaded, instead of printing it and
  carrying on. That is the case where regenerating lists is not enough and
  somebody has to decide something.
- Untracked two .idea files that are per-developer state - test-panel column
  widths and the selected deployment target - which churn on every run and were
  committing the model name of whatever phone was plugged in.

Not done: any user-visible indication. The first login or refresh after this
spends a couple of seconds downloading and provisioning in the background with
nothing on screen. That wants a setup step, and a settings line saying which
Anisette is in use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They are skipped by default, which makes them easy to not know about - and the
reason they are skipped is easy to mistake for "unfinished". It is the opposite:
they talk to Apple, download from Apple's CDN, and provision a new machine
identity with Apple on every run. That is worth an opt-in no matter how settled
the code is.

Also documents the part that passing does not tell you. The app ships generated
stand-ins for two of Apple's libraries, and each reports itself under the
`adi-stub` tag when called: INFO for symbols already known to be harmless, ERROR
for anything else. An ERROR there means the result may be silently wrong rather
than obviously broken, which is exactly the kind of thing nobody looks for
unless told to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rule 4 said local Anisette does not work. That is still true of FindMy's own
`aniLocal`, which needs the unicorn emulator Chaquopy cannot build - but the app
now runs Apple's ADI libraries directly, which is a different mechanism needing
no emulator. Left as it was, the rule would have told the next person the
feature they were looking at was impossible.

The rest of rule 4 was right, and sharper than I had been about it elsewhere:
sessions are bound to one machine identity, and changing it requires a re-login.
Local and remote Anisette present different identities, so the fallback crosses
exactly that line. The rule now records why accounts are still serialized as
aniRemote, and that the provenance warning exists so this reports itself instead
of presenting as auth mysteriously failing. That warning is not noise to be
tidied away later.

Rule 10 is new, and is here because I caused it: adding
check-adi-libraries.yml without adding it to the CI table in CONTRIBUTING.md.
Nothing failed - the table was just quietly wrong, which is worse than missing,
because the next person trusts it. Lists of things go stale in a way that prose
does not, so they need naming.

Also adds the missing CI table row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Foundation only - the toggle is not reachable from the UI yet, and choosing
"remote" does not yet stop local Anisette being used. Committed at this point
because the part that is here is the part worth getting right, and two of its
decisions changed the plan.

**Upgrading from 1.0.5 must not switch anybody to local.** A session is bound to
the machine identity that established it, so an existing login moved onto local
Anisette would be a new device as far as Apple is concerned - every existing
user pushed through 2FA on update, for no reason they could see. So the stored
mode is deliberately nullable and `resolveAnisetteMode(hasExistingSession)`
decides: local for new sessions, remote preserved for existing ones. Reading a
missing value as "local" in a getter would have been the whole bug, quietly.

`anisetteUpgradeOffered` supports asking those users once whether they want to
switch, at the moment they can see what they would gain, rather than moving them
without being asked.

**The login screen cannot lose the server field outright.** It is hidden now,
since signing in normally needs no server. But Settings is behind a login, so
somebody signing in for the first time on a device where local Anisette fails
would have no way to reach the setting that would fix it. Hidden and revealed on
failure, rather than deleted.

Also here: 21 strings across all ten locales via scripts/add_strings.py, the
mode picker and local-status layouts, the manager methods that drive them, and
wiki links in app.properties for what an Anisette server is and where to obtain
an Apple Music APK.

Still to do: LocalAnisette honouring the mode, the Settings screen reaching any
of this, the file picker for a user-supplied APK, the upgrade prompt, and the
login screen revealing the server section when local Anisette cannot start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things: LocalAnisette now declines when a remote server is selected, so the
setting is no longer inert, and the Settings dialog offers the choice.

The dialog shows one mode's controls and hides the other's rather than greying
anything out - a server URL and a test button have nothing to say when nothing
is being asked of a server. Its warning now covers both cases, because changing
the mode presents Apple with a different machine exactly as changing the server
does, and takes the same re-login path on save.

Choosing a mode also marks the upgrade prompt as offered. Someone who went to
Settings and decided has answered the question; asking them again afterwards
would be the app not listening.

The settings row is titled for what it controls now rather than for the server
it used to be, and its subtitle reads "On this device" instead of a URL that no
longer applies.

Known gap, deliberately not papered over: the local status block renders
"sets itself up next time you sign in" unconditionally, because nothing queries
LocalAnisette to fill it in yet. Doing that from a dialog means work on a
background thread and a state to show while it runs, which belongs with the
remaining items rather than bolted on here.

Still to do: that status, the file picker for a user-supplied APK, the one-time
upgrade prompt, and the login screen revealing the server section when local
Anisette cannot start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LocalAnisette downloads Apple's libraries, talks to Apple's servers and
provisions a machine identity. Every state the settings UI has to render is
therefore reachable only by arranging the real world, and several cannot be
arranged at all - "Apple shipped a new build whose hashes no longer match" is
not something you can produce for a test, and it is precisely the state that
reveals the supply-your-own-APK controls.

The interface is the whole seam: ensureReady, unavailableReason,
isChangingMachineIdentity, otp, machine, recordSessionProvenance, describe. It
says nothing about downloads, hashes or Apple, because a caller that needed to
know any of that would be a caller that could not be given a fake.

No behaviour changes. The Python bridge is unaffected - Chaquopy calls these by
name, and the object it is handed still answers to them.

Next: a fake behind this in androidTest, and Espresso coverage of the states it
makes reachable - ready, setting up, offline, manifest mismatch, user-supplied
APK accepted and rejected, and the one-time upgrade prompt. That prompt is the
one worth having tests for most: it fires once per install, for people who
already have a working login, and getting it wrong moves a live session onto a
different machine identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Consolidates the two copies of the show-one-mode-hide-the-other logic - I wrote
one in SharedMainSettingsManager before noticing the settings screen uses
dialogs rather than the shared block, and then wrote it again in
SettingsActivity. It is now one static method taking a root view, which also
makes it testable by inflating the layout: no activity, no account, no Anisette.

FakeAnisetteSource covers the states the real one cannot be made to produce:
ready, unavailable, unavailable-after-a-local-session, and Apple having replaced
the libraries. That last one is the important one - it is the only state that
reveals the supply-your-own-APK controls, and it depends on Apple shipping a
build, which they last did in April 2025.

Honest about what these five tests are: regression guards, not discoveries. They
all passed the first time they ran. The only thing they caught was a bug in
themselves - setting the dropdown's text runs through TextInputLayout, which
animates its label and throws off the main thread. The production code they
cover is twenty lines of setVisibility written minutes earlier, so there was not
much room for it to be wrong.

What they are worth is the case they describe: getting this backwards leaves
somebody editing a server URL that nothing reads, or reading a local status
while their sign-ins go to a server. The app runs, nothing throws, and the
screen looks plausible either way.

The tests likely to earn their keep are the ones the fake now makes possible -
status population, APK verification, and the upgrade prompt, which fires once
per install and moves a live session onto a different machine identity if it is
wrong.

Also fixes the settings row subtitle, which showed a server URL even when
sign-in no longer used one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Working out the status blocks: it downloads, hashes and can talk to Apple.
Drawing it must happen on the main thread. Putting a value between the two -
AnisetteStatus - lets each half be tested without the other, and is what the
settings screen needs anyway to fetch in the background and render on the main
thread.

The distinction that does real work is "Apple shipped a new build" against every
other failure, because it is the only one where supplying an Apple Music APK by
hand would help, and so the only one that offers to. Wrong in one direction and
people are told to go and find an APK when their wifi is off; wrong in the other
and the escape hatch never appears in the one situation it exists for.

That is matched on a marker string shared with AdiLibraryManifest rather than a
typed error, deliberately. A typed error would put knowledge of downloads and
hashes into an interface whose whole purpose is that a fake can implement it.
The fake uses the real marker too, so changing how this is detected breaks the
test rather than quietly making it test nothing.

Eleven tests, and - as with the last batch - they all passed first time. They
found nothing. What they cover is the branch that cannot otherwise be reached:
Apple last shipped an Apple Music build in April 2025, so without a fake that
path would go unexercised until the day it mattered, which is precisely the day
nobody wants to discover it was wrong.

Also fixes a size mismatch reporting differently from a hash mismatch, which
would have classified as an ordinary failure and hidden the APK controls despite
being the same problem with the same remedy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The status block has been rendering "sets itself up the next time you sign in"
unconditionally since it was added, because nothing asked. It now asks, off the
main thread, and applies the answer on it - which is what splitting
AnisetteStatus from its rendering was for.

Two decisions worth stating, because both look like oversights otherwise.

**Opening this dialog can do the setup, not just observe it.** There is no way
to know whether something will work without trying, and a status reading
"unknown" would be worthless on the one screen built to answer the question.
Somebody who has opened the Anisette settings is a reasonable person to spend
that on: they are looking at the result, and it explains itself.

**Only in local mode.** Somebody using a remote server should not have 3 MB
downloaded on their behalf to populate a status they are not reading.

Known gap: there is no progress indicator, so on a slow connection this reads
"sets itself up the next time you sign in" for a few seconds before changing to
"Ready". That is true rather than misleading, but it is not the same as saying
something is happening.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Finding out whether local Anisette works can take a full connection timeout -
offline is the worst case, waiting thirty seconds before failing. Until now the
dialog showed "sets itself up the next time you sign in" for that whole time,
which is true but reads as a screen that has hung.

CHECKING is a separate state rather than a flag on PENDING, because they mean
different things: one says nothing is happening, the other says something is.
Conflating them is how a spinner ends up either permanent or absent.

Three tests, and the two worth having are the ones asserting the spinner stops -
on success and on failure. A spinner that never goes away is a worse bug than
never showing one, and it is invisible in the happy path where everything
resolves in a few hundred milliseconds on a fast connection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signing in no longer needs an Anisette server, so the field for one was hidden
on the sign-in screen. That creates a way to strand somebody: Settings is behind
a login, so a first run where local Anisette cannot start left no route to the
one setting that would let them in.

The screen now asks local Anisette first, off the main thread, and only falls
back to testing a server when it says no. When it says yes there is no server
test, no field, and nothing gating the button - which also fixes the case where
an unreachable default server disabled "next" on a screen with no visible field
to fix it on.

Two things that only surface once a login can skip the server step entirely:
the stored server URL may legitimately be null, where the login button used to
requireNonNull it, and the field has to explain itself when it appears - unless
a server was chosen deliberately, in which case nothing failed and saying so
would be wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Espresso needs three things this repo did not have.

A window with focus: a guest window only has focus while the emulator's own
window has focus on the host desktop, so every UI test failed the moment you
alt-tabbed away from it. The managed device is headless and has no such notion,
so it is now what the script and CI both run - the same device in both places,
about five times faster, and no second emulator definition in CI to keep in step
with app/build.gradle.kts.

An activity to host a dialog. The app's own activities restore an Apple session
before they finish starting. TestHostActivity is empty and lives in the debug
source set rather than androidTest, because an activity declared in the
instrumentation manifest belongs to the test package and ActivityScenario
refuses to launch across the process boundary.

Animations off, which is a documented Espresso requirement rather than a
workaround: it will not touch a window that is still laying out.

Separately, the runner script no longer restarts a healthy emulator and repeats
the whole suite when a test genuinely failed - it retries only when a run died
before anything reported.

And add_strings.py grows --show and --replace. Adding was already handled;
rewording was not, and it has the worse failure mode - a locale you miss keeps
the old wording and still passes --check, so nothing complains and the app says
two different things in two languages. The intermediate JSON also puts all ten
translations in one reviewable diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Anyone updating is deliberately left on their Anisette server, because a session
is bound to the machine identity that established it and moving them without
asking would put every existing user through two-factor authentication on
update, for a reason none of them could see. But local is the better position,
so it is worth asking once, at the moment they can see what they already have.

Once is the operative word: the offer is recorded when the dialog appears rather
than when it is answered, so dismissing it does not bring it back on the next
launch. Anyone who chose a mode for themselves is never asked at all - they
answered the question by choosing.

Accepting saves before signing out, in that order. The other order looks
identical until the write loses a race with the activity finishing, at which
point they sign in again and arrive back on the server they just left.

Nine Espresso tests, on a bare host activity so no Apple session is involved.
Two of them are about wiring that is wrong by default: dismissal follows a
button press as well as a cancel, so the obvious listener fires twice and races
its own save; and a dialog delivers that listener through a posted message, so
a test that asserts straight after cancelling sees nothing.

The prompt text is now two sentences rather than two paragraphs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signing in is the part of the app with the most ways to go wrong and, until now,
no coverage at all: four pages, transitions fired from three different places,
state carried across them, and a six-box code entry where each box rewrites its
neighbours. None of it could be exercised, because every step ran Python against
Apple with real credentials and sent a real code to a real phone - so the only
test was somebody doing it by hand, along whichever path they happened to take.

AppleAuthService is the seam. PythonAppleAuthService delegates to the existing
static methods rather than moving them, so this adds a way in and changes no
behaviour. LoginDependencies holds it and the Anisette factory - a settable
global, because activities are constructed by the framework and this app has no
DI container; production never calls the setters.

Eight Espresso tests over the real activity, layouts, view model and encrypted
session store. They cover reaching the map with a texted code, pasting a code
into the first box, the code going to the number that was actually chosen, an
account that needs no second factor, a rejected password leaving the form usable,
a rejected code giving the boxes back, an account with no phone numbers, and the
sign-in carrying both local Anisette and a fallback server URL.

Two things about testing this were not obvious. Espresso waits for the main
thread and for nothing else, while every step here runs on an Rx scheduler - so
assertions retry rather than assume. And pasting a correct code finishes the
activity, which means a second ViewAction in the same perform() runs with no
activity left and fails pointing at the test instead of the code.

TestPace adds an opt-in pause between steps, so a person can watch a flow instead
of seeing it flicker past. Off by default; documented with its prerequisites in
AGENTS.md and CONTRIBUTING.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This is the path that decides whether somebody can get in at all when the normal
route fails, and nothing exercised it: local Anisette works on virtually every
device, so the fall-back only runs for people already having a bad day. It is
also the path with no way back, since Settings is behind the sign-in.

Testing it needed one more seam. The screen checks a server is alive before
letting anyone past, so without replacing that, a test of the fall-back would
depend on a stranger's Anisette server being up - which is the situation the
fall-back exists to survive.

The tests immediately found a real bug. activity_apple_login.xml was nested
LinearLayouts with no scroll container, and revealing the server section makes
the screen taller than a Pixel 6: the button below it goes off the bottom with
no way to reach it. It passed on a tall emulator and failed on the managed
device, which is exactly the shape of bug that ships. Wrapped in a ScrollView
with fillViewport.

Three things about writing these were worth recording in the comments. Espresso
retries have to cover PerformException, not just assertion failures, or a
scrollTo against a view that is still being revealed fails on the first attempt
and never tries again. A GONE view still matches withId, so "not on screen" is
matches(not(isDisplayed())) and not an expected NoMatchingViewException. And
each test has to start with no server stored, because with a reachable one the
app correctly skips the welcome step altogether - the test was looking for a
page the app was right not to show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only conflict was the tooling-tests heading in CONTRIBUTING.md: main broadened
it from "Translation string tooling tests" to "Tooling tests" while this branch
edited the text underneath. Main's heading is the right one - scripts/test now
covers the release and macOS tooling too, not only strings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Apple serves exactly one build of the APK these libraries come from. When they
replace it the recorded hashes stop matching, and local Anisette cannot set
itself up until this app ships an update - so the controls for this were already
on screen, and did nothing.

Now they work. A chosen APK is unpacked, every library checked against the same
recorded SHA-256 as Apple's own copy, and the result written into the directory
the loader already reads from - so nothing downstream knows or cares where the
bytes came from, and the fetcher skips the network once they are there.

Where the file came from cannot make it more dangerous: a wrong or hostile APK
is rejected by exactly the check that rejects a corrupted download. Nothing is
kept unless every library matched, which matters more than it sounds - the
fetcher skips files that already exist, so a half-written import would be picked
up on the next sign-in as though it had been verified. "Go back to Apple's copy"
deletes the extracted libraries for the same reason.

Six tests, all on the refusals: the wrong APK, the right names with wrong bytes,
libraries for another CPU, something that is not a zip at all, and that a
rejected import leaves nothing behind. Accepting the real thing needs the real
100 MB APK and belongs with the other opt-in tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LoginDependencies was never login-specific in anything but name: Settings builds
Anisette to show its status, and the map builds it to restore an account, and
both did it by construction - so neither could be driven by a test. Renamed to
AppDependencies and both now go through it.

This is issue #50 in its Anisette-shaped form. It does not finish that issue:
driving MapsActivity still needs a system image with Play Services, which the
aosp-atd managed device has not got.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The importer had tests; the screen that reaches it had none. Nobody had clicked
"Choose a file" except by hand, and the state that reveals the button cannot be
produced on demand - it needs Apple to have shipped a new build, which they last
did in April 2025.

Four tests through the real Settings screen, with the file picker answered by
Espresso-Intents rather than appearing: the controls show up when Apple changed
the libraries, choosing asks the system for a document, a file that does not
match is refused and not recorded, and backing out changes nothing.

A successful import is deliberately not here. Any file this test could build
fails the hash check by design, and weakening that check to make a test pass
would remove the only thing between a file off the internet and code this app
loads and runs.

One finding rather than a test tweak: UserSettingsRepository stores an empty
string for an absent APK URI, not null, so asserting null would have passed
without meaning anything. The tests ask hasOwnAnisetteApk() instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bugs, both visible in one screenshot of the flow.

The refusal was a toast. Android caps those at two lines and then takes them
away, so the sentence naming the library whose hash did not match was cut off
mid-word - the one piece of information that tells somebody whether to go and
find a different copy. It now sits in the dialog, where there is room and it
stays put until the next attempt.

Worse, the status was left saying "Checking..." for ever. Importing turns the
spinner on, and the early return for a refusal skipped turning it off - on a
screen somebody only reaches because something is already broken. Exactly the
failure the spinner tests were written for, on a path they did not cover.

Two tests: the reason is displayed in full, and a refused file stops the
spinner. The second fails against the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It chooses where sign-in data comes from, and in the usual case that is this
device - no server, no URL anywhere in it. A new string rather than reworded:
the login screen uses the old one to label a field that really is a server URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…install

Two ways these tests destroy state on whatever device they run on, one of which
I caused and one of which was always there.

The tests overwrite UserSettings to get a known starting point - signed out, no
server chosen - which also discards the language, theme, map provider and an
AMap key somebody had to register for by hand. DeviceStateGuard captures all of
that before the first write and puts it back afterwards, session included.

The bigger one is not fixable in a test: connectedAndroidTest uninstalls both
APKs when it finishes, so everything the app stored goes with them - session,
settings, and every imported beacon and its location history. allowBackup is
false, so on a real phone that is unrecoverable and means redoing the macOS
export. It is now a warning in AGENTS.md and CONTRIBUTING.md, next to the
slow-motion instructions that were telling people to run exactly that.

Found because the upgrade prompt reappeared on an emulator that should have been
past it - the app looking broken while doing exactly what it was told.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pgrade

Signing in for the first time offered the upgrade prompt - inviting somebody who
had just signed in using Anisette from their own phone to switch to Anisette
from their own phone, at the price of signing in again.

Nothing wrote the mode when a session was created, so a fresh sign-in left it
unchosen, and "signed in with no mode chosen" is precisely how somebody updating
from an older version looks. The same gap emptied the Settings row: its subtitle
was the stored server URL, which a local sign-in never sets.

recordSessionProvenance existed for this and was never called by anything, so
isChangingMachineIdentity has always been false and the fall-back warning that
AGENTS.md rule 4 insists on has never once fired. Both are wired up now.

Null still means nobody has decided: someone updating from a version without any
of this never ran this code, keeps their null, and is still asked once.

Also, from the same screenshots: the settings row is titled "Anisette Provider"
rather than "Sign-in data" and never renders blank, and the re-sign-in warning
waits until the mode actually differs instead of greeting everyone who opens the
dialog to look.

Six tests. The important one was checked against the broken code first:
aFreshSignInIsRecordedAsLocalAndIsNotOfferedAnUpgrade fails with the recording
removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correcting the previous commit. It recorded provenance from the status shown
when the sign-in screen opened, which is a guess - and it also overwrote a
correct value, because the Python sign-in already records this from the one
place that knows. Python consults local Anisette itself and falls back on its
own, so a sign-in that started local and fell back part-way was being filed as
local, and the fall-back warning would then stay silent in exactly the case it
exists for.

The screen now reads what Python recorded rather than inferring it, through a
new AnisetteSource.wasSessionEstablishedLocally. FakeAppleAuthService records it
the same way the real sign-in does, so the tests exercise the real handover
rather than a shape only they see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Changing the theme, language or map provider makes AppCompat relaunch every
activity in the process - including the map underneath the settings screen. The
rebuilt map got a brand-new RefreshPolicy, so it believed it had never fetched,
and its startup fetch was unconditional anyway. Both together meant a full walk
of every tag's key history each time. Toggling dark mode three times was three
full fetches, which is the traffic AGENTS.md rule 6 is about, not just slowness.

Two changes. The policy is now shared for the life of the process, because "when
did we last ask Apple" is a fact about the app rather than about a screen. And
the startup fetch asks it first, returning an empty stream when the interval is
not up - empty rather than absent, so the loading indicators, initialFetchComplete
and the periodic refresher all still run exactly as before. The cached locations
are already drawn by that point, so the only visible difference is the absence of
a "locating your tags" run nobody asked for.

Three JVM tests. Two of them fail against the previous behaviour: a rebuilt
screen used to get its own policy, and a fetch made before the rebuild used to
be forgotten.

Found from logcat while looking at something else - "Schedule relaunch activity:
MapsActivity" immediately after "Selected theme choice=Dark Theme".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@parawanderer

Copy link
Copy Markdown
Owner Author

Follow-up filed as #63: switching the managed device to a google system image, so instrumented tests can touch Maps. Deliberately kept out of this PR — it changes CI for every job, and this one should stay a signal about the Anisette work.

It is what blocks the two gaps noted above: MapsActivity's upgrade-prompt call site (part of #50), and driving a real activity recreation through the map to prove the refetch fix end to end rather than at the RefreshPolicy level.

CI failure. closeSoftKeyboard() asks the IME to go away and does not wait, so a
click issued straight after can land while the keyboard is still over the
button. Fast machines hide it in time; the CI runner does not, which is why this
passed here every time and failed there.

The same fix was already applied to AppleLoginFallbackFlowTest when it turned up
locally - I fixed the class in front of me and did not look at the identical
code in its sibling. All the click sites are consistent now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@parawanderer

Copy link
Copy Markdown
Owner Author

@parawanderer
parawanderer merged commit db2bdf7 into main Aug 11, 2026
3 checks passed

This branch was previously deployed

1 inactive deployment
Android Build — acf4f43a Deployed Aug 11, 2026 by parawanderer via build #57
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.

TODO: Update app to run local Anisette Server

1 participant