Skip to content

Merge SadGare's fork: FindMy 0.9.x, map providers, and working key alignment - #49

Merged
parawanderer merged 50 commits into
mainfrom
feat/sadgare-fork-merge
Aug 9, 2026
Merged

parawanderer merged 50 commits into
mainfrom
feat/sadgare-fork-merge

Conversation

@parawanderer

@parawanderer parawanderer commented Aug 9, 2026 •

Copy link
Copy Markdown
Owner

Merges SadGare's fork (#44) with their authorship preserved, plus the fixes needed to make it work end to end.

Closes #16, #30, #38. Likely helps #36, #23, #35.

Gare's work

Commit What
29a15e4 Map provider abstraction + AMap implementation
962ee6a FindMy 0.9.x backend, unicorn stub, zh locales, startup picker

Cherry-picked rather than squashed into my own commits, so git log --author still finds them.

One commit from the fork is deliberately excluded. Gare wrote the Migration(1,2) and the lazy accessory_json backfill correctly in 53c61d7. 4862cf7 ("remote old findmy backend related code"), 80 minutes later, deleted both and added a test asserting they stay deleted. Skipping that commit was the fix — none of their work needed rewriting.

Two things that looked like bugs in the fork are forced adaptations, verified against a real 0.9.8 install: fetch_last_reports/fetch_reports no longer exist, and LocationReport.published_at/.description are gone. The client-side filtering and getattr(report, "description", "") are correct.

Key alignment — the substantial fix

Without a KeyAlignmentRecord, FindMyAccessory starts at index 0 from its pairing date, so the first fetch searches the tag's entire life. Measured on real tags: 50,588 indices ≈ 175 sequential requests, per tag. That is the account-flagging risk in #30, quantified, and it applies to every import.

macOS does store it, confirmed on a real Sonoma install:

~/Library/com.apple.icloud.searchpartyd/KeyAlignmentRecords/<accessory-uuid>/<record-uuid>.record

Same nesting and same BeaconStore key as BeaconNamingRecord — it was excluded only by WHITELISTED_DIRS. Now wired end to end: the wizard exports it (format 0.0.2), the importer parses it, migration 2 → 3 persists it, and it reaches FindMyAccessory.from_plist(plist, key_alignment_plist).

Writing the tests found that this path was entirely dead code. AppleZipImporterUtil had the enum constant, the switch case, the map, the column and the migration — but no entry in MATCHERS, the only thing getAllowedFileType iterates. Every record was silently dropped. Nothing failed; imports just kept doing the full search. It survived because no real 0.0.2 zip has ever been produced, and on-device testing exercised the fallback probe, which works without a record.

For exports without one, main.py fetches the latest location instead of the history when the window is wide — one traversal, still narrows it. Verified live:

Key search window is 50588 indices wide; fetching latest location only...
Key search window narrowed from 50588 to 95 indices

Other fixes found by running it

  • Fetch was all-or-nothing. Python returned one dict at the end, so quitting a long import discarded the alignment for every tag including finished ones. Java now drives the loop and stores each result as it arrives.
  • Refreshes queued up. Serialising calls into Python fixed an event-loop crash but let the 60s refresh stack one entry per minute during a long fetch, then fire them all. The periodic path now skips its turn while Python is busy.
  • Tag cards: text column offset by 51dp, ragged card heights, and the button row behind the navigation bar.
  • AppCryptographyUtilTest asserted the whole keystore held one alias, so it passed once and failed forever after on any device that had signed in.
  • Chinese language options, lowercase "password", doubled alignment probe.

Also in here

  • Instrumented tests on a Gradle managed device — ./gradlew :app:testEmulatorDebugAndroidTest, 24/24 from cold in 1m37s with nothing booted first
  • Contributor grid on the Information page, generated at build time so the app never calls the GitHub API at runtime
image
  • Device list: empty state with an import button, long-press to remove
image
  • String tooling (scripts/add_strings.py) with a --check that found 8 strings missing across 7 locales
  • CI: translation check, pyright, and tests for the tooling; scripts/** added to the path filters, since the build already shelled out to it
  • A pre-commit hook running the translation check plus flake8 and pyright on staged Python, scoped to match what CI actually gates so it cannot be stricter than the build

Added while testing the wizard on a real macOS 14 VM

Getting this branch onto a bare Mac turned out to be most of the work, and none of it was written down anywhere.

  • scripts/bootstrap_macos.sh — one command from a freshly installed macOS to a running wizard: Command Line Tools, clone, virtualenv, dependencies, launch. Re-runnable.
    • Tests for the tools by running them, not by looking for them: a bare macOS ships stubs at /usr/bin/git and /usr/bin/python3 whose only job is to trigger the installer, so "is it on PATH" answers yes long before either works.
    • Picks an interpreter by whether it can actually construct a Tk() window. The Command Line Tools Python links the system Tk, which aborts during Tk_Init with Abort trap: 6 and no traceback if it decides the OS is too old — import tkinter succeeds right up until it doesn't. Falls back to printing the CLI route, which needs no GUI at all.
  • The wizard now runs on Python 3.9, which is what the Command Line Tools install and therefore what anyone running from source on a stock Mac actually gets. It was crashing at import on MACOS_VER: tuple[int, int] | None — 3.10 syntax at module level. Fixed with from __future__ import annotations, and 3.9 added to the CI matrix so it cannot regress.
  • TESTING.md → CONTRIBUTING.md (via git mv, history kept), with setup, every test suite, which Python each tree targets, and the macOS bootstrap.
  • Recommends installing gh so an agent can read its own CI failures instead of guessing.

Exporter versioning

VERSION in python/main/wizard.py is the only place the exporter's version is written, and it is stamped into every export as via: OpenTagViewer.app:<version> — which is how a zip gets traced back to the exporter that built it. Nothing in the release pipeline rewrote it: the tag named the artifact, the source kept whatever was committed. Tagging macos-exporter-v1.0.5 against a tree still saying 1.0.4 would have shipped a build calling itself 1.0.4 everywhere a user can see, and python/README.md had already drifted to a version the source never declared.

scripts/exporter_version.py --tag <tag> now checks the two agree, in test-release-version, before either PyInstaller build starts. Both build jobs take the version from that job's output, which also removes the two copied awk/cut blocks that each re-parsed the tag. Releasing is deliberately two steps now — the bump is a commit, the tag only publishes it.

Fixed by making the source authoritative rather than patching the tag in at build time: the wizard also runs from source, and those exports stamp via: too, so a build-time patch would leave two artifacts from one commit disagreeing about their version.

VERSION bumped to 1.0.5, since this release changes what the zip contains.

Found by running the finished branch on a phone

Everything above was written before the branch had been used in anger. Importing a real 0.0.2 zip and clicking around found six more, none of which threw an exception, appeared in logcat, or would have been caught by any test that existed at the time. They are grouped here because the shape is the point: every one was a capability that quietly stopped being invoked.

Importing three tags produced one card. onImportFilePicked forked into an Observable.zip: one branch fetching reports, the other parsing metadata. zip completes when its shortest source does and disposes the rest — harmless while Python returned one dict for the whole batch, silently destructive once the fetch emitted per accessory. It took the first pair, completed, and cancelled accessories two and three before they were ever requested. A disposal is not an error, so nothing downstream could notice, and the tags sat in the database looking perfectly correct.

Selecting a tag did nothing visible. Tags kept in the same place resolve metres apart, which at any normal zoom is a single pile of markers. Raising the selected one existed before 29a15e4, as Marker.setZIndex, and was dropped when markers moved behind IMapProvider — MapPolyline kept a zIndex, MapMarker never gained one. The constants and the lastFocusedMarker field survived as unreferenced fossils, so the code still looked present. Restored for both card selection and marker taps, and MapMarker now carries a draw order that both providers honour.

The periodic refresh was off for the whole session. The startup stream completes without emitting when nothing is stored — a first run, or before any import — so initialFetchComplete, set in onNext, stayed false forever. The import path never set it either, so the one case guaranteed to hit both was a fresh install importing its first zip.

History showed one populated day and empty days behind it. _fetchReportsForAccessory took start and end and never passed them to the fetch, because there was nowhere to pass them: 0.9.x dropped the range parameters, and for a rolling-key accessory fetch_location_history delegates to _fetch_accessory_reports(..., only_latest=True), which walks back from now and returns at the first batch that yields anything — about one day. Every range returned today's reports, which the caller then filtered away to nothing. Rebuilt on accessory.keys_between(start, end) plus fetch_location_history(list_of_keys), generating the window's keys and feeding each report back into the accessory's alignment — the part the library cannot do, since it is handed bare keys and does not know which index each came from. Capped at 255 keys per request and 8 requests per fetch, because a history screen must not become a new route to the #30 problem.

A network timeout looked like a day with no reports. A day is one request; one aiohttp timeout emptied it. Requests are retried once now. More importantly, a wholly failed range used to return [], and mapResults only raises when Python returns None — so a dict merely missing a beacon read as "no reports for that day". The screen rendered a confident 0 reports for a day it had failed to fetch, and never reached the Retry button that already exists in view_history_bottom_sheet.xml. Total failure now returns None; partial results are still returned and logged as short.

Two latent bugs the new tests found on their own: the first fetch for a beacon stored Apple's response unsorted while every caller reads the last element as newest (a stale position on the map, for freshly imported tags only), and hoursToGoBack could return 0 when two fetches landed in the same millisecond — asking Apple for zero hours returns nothing, which is indistinguishable from a tag that has not been seen.

Tests, and why these ones

The branch started with an empty ExampleUnitTest. It now has 72 JVM, 33 instrumented, 43 Chaquopy bridge, 39 tooling and 29 wizard tests.

The bugs above all failed silently, so the tests are shaped to catch that specifically — they assert that a call happens, not that a value looks right:

Where What it pins down
RxFlows a fast branch must not truncate a slow one; accessories fetch one at a time; a failure skips one item, not the batch
MarkerFocus selecting lowers the previous marker before raising the new one; a rebuilt marker stays raised across a refresh
RefreshPolicy a tick during a long fetch is skipped rather than queued; the history window never asks for zero hours
BeaconLocationHistory newest report is last; a beacon with no location cannot be drawn
LongFetchBannerState the banner's counter survives overlapping fetches
TagCardLayoutTest cards measure to a uniform height; the scroll row does not clip the shadows
test_main_ranged_fetch.py the right keys are requested, batching stays under Apple's limit, alignment is updated, a failed range is an error rather than an empty day

Three of them are deliberately paired with an inverse assertion, because a guard that can only pass in one direction is not a guard: wrapContentIsWhatMadeTheRowRagged asserts the broken layout really is ragged, and the ranged-fetch tests assert that a genuinely empty range is not an error while a failed one is.

Much of this required extracting logic out of MapsActivity — 1,537 lines, every repository built in onCreate, nothing reachable from a test. What moved out is decision logic only; the remaining orchestration, and the UI tests it would unlock, are #50.

Verified

  • assembleDebug green; 72 JVM, 33 instrumented, 43 Chaquopy bridge, 39 tooling, 29 wizard tests
  • A 0.0.2 zip built by this branch's wizard, imported on a physical phone. main cannot export KeyAlignmentRecords at all, so the zip could only have come from here. All three records were read by the importer, and the key search window opened at 2,582 indices instead of 50,588, then narrowed to 95. Wizard → zip → importer → migration → FindMyAccessory → narrow first fetch, on live Apple data.
  • History across multiple days, fetched and rendered per day against live Apple
  • Clean 60s refreshes with correct de-duplication on overlap (8 held + 1 fetched = 8), and no app-level warnings or errors anywhere in logcat
  • CI is green on all three jobs — Static checks, build, and instrumented tests on an emulator. Its first ever run found three latent bugs unrelated to this branch (a build step calling a script deleted months ago, pyright with no packages installed, pyright without Pillow); all three are fixed here.
  • The wizard runs on a real macOS 14 install from bootstrap_macos.sh, against real com.apple.icloud.searchpartyd data
  • KeyAlignmentRecords exists on real macOS with the expected structure

Not verified — please don't assume otherwise

  • AMap. Needs a mainland-Chinese account with real-name verification. @SadGare — could you confirm it still works after the merge? The marker draw order added here had to go in reflectively like the rest of that class, so it degrades to an unordered marker rather than no marker if the method name ever changes. Replace AMapProvider's reflection with direct SDK calls #51 tracks replacing that reflection with direct calls.
  • v1 → v3 on real data. Covered by tests, never run on a device holding real tags.

Note on AMap keys

AMap issues keys per developer account, bound to a package name and signing fingerprint, and expects the key holder to be the app's operator. Rather than bundling one, users supply their own in Settings; selecting AMap without a key does not save. There's a wiki page covering registration.

SadGare and others added 30 commits August 8, 2026 16:24
Squashed from SadGare/OpenTagViewer (issue #44), commits ecbc444..43735a2.
The original series carried a lot of iteration ("generate sha", "add sha",
"rectify the workflow") that is not worth preserving individually.

Rather than bolting a second SDK onto MapsActivity, this introduces IMapProvider
with GoogleMapProvider and AMapProvider behind it, plus provider-neutral
MapMarker and MapPolyline types and a MapProviderFactory. MapsActivity and
HistoryViewActivity are reworked onto the interface, so a future provider is a
new implementation rather than another rewrite. This is what issue #16 actually
asked for.

CoordinateConverter handles the GCJ-02 ("Mars coordinates") offset that mainland
China mandates; without it pins land hundreds of metres out.

Note for review: com.amap.api:3dmap is a new third-party dependency with its own
telemetry posture, and it brings a second API key (AMAP_API_KEY) into the build.
AMap keys are bound to a signing fingerprint, which is what the signing-report
workflow in this series is for.

Closes #16

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

Squashed from SadGare/OpenTagViewer (issue #44): 53c61d7, 719299f, eb48b8b
and c2c0772. Deliberately excludes 4862cf7 - see below.

FindMy.py 0.9.x makes FindMyAccessory stateful: it carries rolling-key alignment
that has to be persisted between fetches, which is the issue #30 fix. That state
is threaded end to end - OwnedBeacon gains accessory_json with a Migration(1, 2)
to add the column, toAccessoryRequests backfills it from the retained plist for
rows imported under 0.7.6, and storeFetchResult writes the updated alignment back
after each fetch. Accounts saved by 0.7.6 cannot be restored by 0.9.x, so a
restore failure clears the stale blob and routes the user to re-login.

FindMy >= 0.9 pulls in anisette, which needs unicorn - a CPU emulator Chaquopy
cannot build for Android. A pure-Python stub makes the tree resolve. This does
not add local Anisette support; remote stays the only path, so #34 remains open.

Also adds zh-rCN and zh-rTW locales, a startup language and map provider picker,
provider-aware dark mode (#38), large-font clipping fixes, and restores the
custom anisette input flow.

4862cf7 ("remote old findmy backend related code") is excluded on purpose. It
was meant as dead-code cleanup but deleted the Migration(1, 2) and the lazy
accessory_json backfill that 53c61d7 had correctly introduced, and added a test
asserting they stay absent. Without the migration, every existing install throws
"A migration from 1 to 2 was required but not found" on first database access
after updating - reproducible only on upgrade, never on a fresh install.

Closes #30, closes #38

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The migration itself came from Gare in the previous commit; this adds the
tooling and coverage that proves it works and keeps it working.

Room was building with exportSchema unset, which emitted a warning and left
no schema JSON to diff. Now that there is more than one schema version -
and a macless export path may well introduce a third - schema changes
should show up as a reviewable diff. The exported v1/v2 JSONs are also what
MigrationTestHelper needs to build an old database to migrate from.

The v1 schema had never been exported, so it was regenerated by compiling
against main's pre-existing entity shape. Diffing the two confirms the only
change is the nullable accessory_json column, which matches the migration's
ALTER TABLE exactly.

The tests cover what a user would actually lose if this went wrong: beacon
rows, their plist content (which the lazy backfill re-reads), soft-delete
state, and location history. Also covers the empty-database case, since a
fresh install that has never imported still has a v1 database to upgrade.

MIGRATION_1_2 is made public so the test APK can reference it reliably.

Co-Authored-By: Gare <gareng@qq.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FindMyAccessory.from_plist() takes an optional key_alignment_plist supplying
lastIndexObservationDate / lastIndexObserved. The export wizard only captures
OwnedBeacons/ and BeaconNamingRecord/, so it is always absent and alignment
falls back to paired_at with index 0.

Key lookup then spans get_min_index(start)..get_max_index(end), and the AirTag
rotation interval is 15 minutes, so the first fetch after migrating to 0.9.x
searches from the pairing date to now. Measured against synthetic plists: a tag
paired 30 days ago needs ~2.9k keys, one paired 2 years ago needs ~70k.

update_alignment() narrows the window after the first success and
storeFetchResult persists it, so this is a one-time cost per beacon rather than
an ongoing one. But it is the "spam queries to Apple and get the account
flagged" risk from issue #30, and it scales with tag age, so it needs measuring
against real exports before release.

The script is offline and read-only - it never contacts Apple and needs no
account. Identifiers are not printed unless --show-identifiers is passed, so
the output is safe to paste into an issue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Debug and release shared applicationId but are signed with different keys, so
installing a debug build onto a device with a release install fails with
INSTALL_FAILED_UPDATE_INCOMPATIBLE. The IDE then offers to uninstall the
existing app to proceed, which permanently deletes the imported beacons and the
accumulated location history. android:allowBackup is false, so there is no
backup to fall back on, and recovering means redoing the macOS export - the
exact thing this app exists to avoid.

Suffixing the debug applicationId lets a debug build install alongside a real
one, which also makes it possible to test the v1 to v2 migration against a
realistic database without putting a production install at risk.

A Maps API key restricted to the release package will not authorise the debug
package; see the note in build.gradle.kts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With the debug applicationId suffixed, a debug build installs alongside a real
one - but both used the same icon and the same "OpenTagViewer" label, so there
was no way to tell which was which on the launcher. That is actively dangerous
here: the two apps hold different databases, and mistaking the real one for the
test one invites exactly the destructive uninstall the suffix was added to
prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Room migration tests live in src/androidTest because MIGRATION_1_2 can only
be exercised on a real Android runtime, and CI only ran JVM-only tasks, so the
regression test guarding against destroying every existing user's beacons and
location history never actually executed. Adds an emulator job running
connectedDebugAndroidTest, with reports uploaded on failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The backfill is what stops beacons imported under FindMy 0.7.6 from being
excluded from every future fetch after upgrading. It had no coverage because
toAccessoryRequests called Python.getInstance() directly, which cannot run on
the JVM or without a started Chaquopy runtime.

Puts the conversion behind PlistToAccessoryJsonConverter so tests can supply a
fake, and adds coverage for the cases that matter: a NULL column is converted
and persisted, an already-populated column is not reconverted, a failed
conversion leaves the column NULL so it stays retryable rather than being
poisoned, one unconvertible beacon does not drop the others from the batch, and
storeFetchResult writes the updated rolling-key alignment back without letting a
null wipe a good value.

Two fixes fall out of the refactor. Python.getInstance() was previously called
on every invocation even when no beacon needed converting, which throws if the
runtime has not started; it is now only reached when a conversion is actually
required. And the backfill now prefers the plist stored on the row over the
caller's copy, since the row is the source of truth and the caller's map can be
stale.

Co-Authored-By: Gare <gareng@qq.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The export wizard captures OwnedBeacons/ and BeaconNamingRecord/ but no key
alignment record, so from_plist gets no key_alignment_plist and a converted
accessory starts at alignment_date = paired_at, alignment_index = 0. The key
search then spans the entire life of the tag.

Measured against a real (redacted) export: a 526-day-old AirTag needs 50,563 key
indices on its first fetch. Apple accepts ~290 keys per request, so that is
roughly 175 round trips for one tag - and it happens for every old tag at once,
the first time the app runs after upgrading. That is the account-flagging risk
raised in issue #30.

fetch_location asks for the latest report only, which makes FindMy.py walk
backwards from now and stop at the first hit rather than traversing the whole
range. Alignment is updated as a side effect of any successful fetch, so one
cheap probe collapses the window for everything after it, and the result is
persisted through updatedAccessoryJson. Worst case - a tag with no reports at
all - it costs the same traversal the history fetch would have done anyway, so
it is never a net loss.

Adds pytest coverage for main.py: the plist conversion against a real redacted
export, the client-side time filtering that replaced 0.7.6's server-side
windowing (including inclusive boundaries), the serialized shape Java's
mapResults depends on, and the probe's threshold and failure handling. Also
guards that the tested FindMy version matches what Chaquopy installs.

Fixtures are a redacted copy of a real export, keeping the true structure so a
parse regression from an unexpected field can actually be caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tests are spread across four locations and three runners, and there was no
single place saying how to run them. TESTING.md lists the commands for each,
what CI covers, and the offline diagnostic scripts that need no Apple account.

The Chaquopy bridge tests were not running anywhere in CI; both the debug and
release workflows now execute them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e CI note

ExampleInstrumentedTest hard-coded the release package name, so it started
failing once debug builds gained an applicationIdSuffix. It now asserts against
BuildConfig.APPLICATION_ID and checks the base id as a prefix, which holds for
both build types.

Also drops the README line about testing and CI not following best practices -
all four suites now run in CI and are documented in TESTING.md.

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

Two anisette changes.

Remote is the only provider that works on Android: local needs the unicorn CPU
emulator to run Apple's ADI blob, which Chaquopy cannot build, which is why the
stub package exists. Previously an account carrying aniLocal state would restore
fine and then blow up later with a NotImplementedError raised from inside the
stub, several layers down in anisette, at whatever moment the provider was first
exercised. getAccount now inspects the serialized provider type up front and
refuses with a readable reason.

The fork blocked changing the Anisette server entirely while signed in, on the
grounds that 0.9.x embeds the URL in the account state. Rewriting that embedded
provider is technically easy - it is just {"type": "aniRemote", "url": ...} - but
it would be wrong: anisette supplies a machine identity derived from that
server's own ADI provisioning, and Apple binds the session to it, so a session
established through one server is not valid when presented with another's. The
app would keep running while silently failing auth. Restores main's behaviour
instead: the change is allowed and triggers a re-login, which the dialog already
warns about.

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

The stub was checked in as a prebuilt .whl. A binary artifact that claims the
identity of a well-known dependency is hard to audit and easy to mistake for the
real thing, so the sources now live in app/stubs/unicorn/ and the wheel is
generated into the build directory.

Generating it during Gradle configuration meant it ran on every invocation
including every IDE sync, and the interpreter probe tried "python3" first, which
on Windows resolves to a zero-byte Microsoft Store App Execution Alias that hangs
indefinitely rather than failing. Builds and syncs appeared to hang forever on any
Windows machine. It is now a task with declared inputs and outputs, and candidates
are ordered per platform with alias stubs rejected by inspecting the file instead
of executing it.

Also adds testAll and testAllOnDevice, wrapping all four suites across three
runners, and TESTING.md documenting how to run each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AMap issues Android keys per developer account, bound to a package name and a
signing fingerprint, and their terms expect the key holder to be the app's
operator. Bundling a key means whoever registered it carries the quota and
compliance liability for every install - and registering one at all requires a
mainland Chinese account with real-name verification, which the maintainers of
this project do not have. A key bound to one signing certificate would not
authorise a differently-signed build anyway.

So no key ships. Anyone wanting AMap supplies their own in Settings, applied at
runtime via MapsInitializer.setApiKey, the same shape as the Anisette server
URL. Selecting AMap without a key does not save; the picker routes the user
straight to entering one and completes the switch afterwards. Clearing the key
falls back to Google Maps rather than leaving a provider selected that can only
render blank.

Registering a key needs the package name and SHA-1 fingerprint, so the dialog
shows both, read from the installed package at runtime, with a button to copy
them. That keeps them correct for whichever build is actually in the user's hand
rather than a documented value that goes stale when signing changes.

Removes AMAP_API_KEY from the manifest, the build defaults and both CI
workflows, since nothing reads it now.

Translated into all nine supported locales. zh-rCN and zh-rTW matter most here:
they are the users this feature exists for, and English fallback would be the
wrong default for exactly that audience. Native review is welcome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Registering an AMap key means working through a Chinese-language console and
knowing to supply the package name and signing fingerprint, which is not
guessable from the dialog alone. Adds a button opening the wiki walkthrough,
with the URL in app.properties alongside the existing project links rather than
hardcoded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The API key gate only covered Settings. The first-run screen has its own map
provider picker which saved the selection directly, so AMap could be chosen
there with no key and nowhere to enter one - leaving a provider selected that
can only render a blank map, on the very first screen a new user sees.

Both screens now share one dialog. Selecting AMap without a key prompts for one
and only applies the choice if a key is supplied; cancelling leaves the previous
provider alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
macOS stores lastIndexObserved / lastIndexObservationDate per accessory under
~/Library/com.apple.icloud.searchpartyd/KeyAlignmentRecords/<accessory>/<record>.record,
encrypted with the same BeaconStore key as everything else. FindMy.py >= 0.9
accepts it as FindMyAccessory.from_plist(plist, key_alignment_plist).

Without it an imported accessory starts at alignment_index 0 from its pairing
date, so the first fetch searches the tag's entire lifetime of rolling keys.
Measured against a real export: 50,563 keys for an 18-month-old AirTag, which at
Apple's ~290-keys-per-request limit is roughly 175 requests for a single tag,
all at once, the first time the app runs. That is the account-flagging risk
raised in issue #30, and it applies to every import rather than only to upgrades.

Confirmed against a real macOS Sonoma install: the folder exists and uses
<accessory-uuid>/<record-uuid>.record, the same nesting as BeaconNamingRecord,
so the existing decryption path handles it with no new logic - it was excluded
only by the whitelist. The project's own manual-export wiki page currently tells
users to skip this folder, which was correct for 0.7.6 and is now the thing
costing them.

The record is optional: macOS does not always have one, and versions predating
key alignment have none at all. Exports without it still work exactly as before.

Bumps the export format version to 0.0.2. The Android importer side is not done
yet, so this only changes what the zip contains.

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

Completes the chain the wizard change started. The exported KeyAlignmentRecord is
parsed from the zip, stored on the beacon, and handed to
FindMyAccessory.from_plist(plist, key_alignment_plist), so an imported accessory
starts at the key index macOS last observed rather than at index 0 from its
pairing date.

Without it the first fetch spans the tag's whole life: 50,563 keys for the
18-month-old tag in the test fixture, roughly 175 requests at Apple's ~290-keys
-per-request limit, for a single tag. That is not an upgrade-only problem - it
applies to every fresh import too, since the exporter never captured this record.

- Room migration 2 to 3 adds alignment_plist, a pure additive ALTER
- Existing rows stay NULL, which is correct: their exports predate format 0.0.2
  and have no alignment record. Those beacons behave exactly as before and still
  rely on the probe in main.py
- The raw record is retained rather than only converted, so a future FindMy.py
  reading more of it can re-derive without a re-export

Covered by a migration test for 2 to 3, one for a direct 1 to 3 upgrade (users
skip releases, so both migrations must run in sequence), backfill tests asserting
the stored record reaches the converter and that its absence is still handled,
and a Python test proving the alignment record actually collapses the key search
rather than merely being accepted.

Also corrects Python type annotations flagged by pyright: several functions
returned None while annotated otherwise, two took `str = None` defaults, and the
alignment record was passed to from_plist as a BytesIO, which only its first
parameter accepts - it now passes bytes, which the signature documents.

Adds AGENTS.md documenting the constraints that are easy to violate by accident:
migrations, remote-only Anisette, no bundled AMap key, both key-alignment paths,
and the Windows traps that have each already cost a debugging session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
buttons_bottom_right was anchored only with layout_above="@id/tags_scrollable_area".
RelativeLayout ignores layout_above when its anchor is GONE, and MapsActivity
hides tags_scrollable_area whenever there is nothing to show. With no other
vertical rule the buttons fell back to the default position - the top of the
parent - and rendered underneath the status bar.

That is every user's first launch, before they have imported anything. It would
not show up for anyone testing with tags already present, which is presumably
why it survived.

Restores layout_alignParentBottom alongside layout_above so there is a fallback
rule. The previous code had it, but it was dropped when the anchoring changed to
follow the tag area.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The language picker lists entries as "<name in the current language> (<native
name>)" - "Dutch (Nederlands)", "Japanese (日本語)". The two Chinese options were
added only to values/, values-zh-rCN and values-zh-rTW, so in every other locale
they fell back to the default and rendered as bare native names, inconsistent
with the rest of the list and unreadable to anyone who cannot already read them.

Adds lang_zh_cn and lang_zh_tw to the seven remaining locales, following each
one's existing convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three problems found by running this against real tags.

The alignment probe ran *before* the history fetch. For an accessory with recent
reports that was a big win - measured 50,583 indices down to 95 on a real tag.
But for one with no recent reports the probe traversed the entire range, found
nothing, narrowed nothing, and then the history fetch traversed the same empty
range again. Double the work in the worst case rather than the best, which is the
opposite of what the comment claimed. It now fetches the latest location
*instead of* the history when the window is wide: same single traversal, still
narrows the window, and still returns something useful.

getLastReports was all-or-nothing. One accessory failing aborted the whole call,
so no accessory's updated alignment was persisted, so the next fetch started from
the same wide range and failed the same way - it never converged. Each accessory
is now isolated, and its alignment is written back even when it returned no
reports, since the alignment may still have moved.

Calls into Python were not serialised. FindMy.py's synchronous AppleAccount drives
a single asyncio event loop, RxJava schedules fetches on a thread pool, and the
periodic refresh fires every 60 seconds regardless of whether the previous fetch
finished. A fetch slower than the refresh interval is normal for an unaligned
accessory, so two threads would call run_until_complete on the same loop and it
would fail with "RuntimeError: This event loop is already running" from then on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Debug builds install alongside a real one, and until now both showed the same
icon. The label differs, but at a glance on a launcher they are identical - which
matters here because the two apps hold different databases, and the real one
holds data that cannot be recovered if it is lost.

A debug source set overrides just the background drawable, with its colours
inverted: the green becomes magenta and the white grid becomes black. The
foreground logo is a .webp and is left alone, so the icon still reads as the same
app while being unmistakably not the release build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous attempt at both was wrong.

For the buttons: adding layout_alignParentBottom alongside layout_above does not
work as a fallback. RelativeLayout resolves ALIGN_PARENT_BOTTOM after ABOVE, so it
wins whenever both are set, and the buttons ended up behind the tag cards instead
of above them. The anchor is the real problem: layout_above is ignored when its
target is GONE, which is how MapsActivity hid the tag area when there was nothing
to show. It now hides it with INVISIBLE, which still occupies its (zero) height,
so the anchor keeps resolving and the layout has one unambiguous rule.

For the icon: inverting the adaptive icon's background had no visible effect,
because the foreground .webp is full-bleed and covers it completely. The
foregrounds themselves are now inverted per density into the debug source set,
preserving alpha so shape masking still works, with a script to regenerate them if
the release icon changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every other field label on the login screen is title case ("Email or Phone
Number"), but this one read "password". Fixed in the locales where case
applies; German was already correct, and the CJK locales have no case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three regressions from the fork merge, all on the map screen.

The text column sat 51dp too far right and was that much too narrow.
layout_weight on the icon container had gone from 1 to 0, which looks
cosmetic but is not: the sibling text column is match_parent, so the row
always measures 102dp over budget and the 1:1 split took 51dp off each.

Cards no longer all had the same height, because they size to their own
content and an address that wraps to two lines makes one taller than its
neighbours. MapsActivity now forces MATCH_PARENT on each card, which
triggers LinearLayout.forceUniformHeight and measures them all to the
tallest.

The button row rendered behind the navigation bar. MapsActivity goes edge
to edge and has no inset handling; that was previously hidden by the tag
area's fixed 240dp height, which left ~70dp of slack below the card that
absorbed the bar by accident. Gare changed that area and both card rows to
wrap_content, so the card now hugs its content exactly. Rather than
restoring the magic 240dp, WindowPaddingUtil gained a bottom-inset helper
alongside the existing top one. Invisible on gesture navigation, ~48dp on
three-button.

clipChildren/clipToPadding are off on the scroll view so the cards' 8dp
elevation shadow can still draw into that padding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Python was handed the whole accessory list and returned a single dict at
the end, so the updated key alignment for every accessory was written in
one go. A tag with no alignment record takes minutes to resolve - measured
at ~175 sequential requests for a 50,000-index search - so quitting
part-way through discarded the work for all of them, including the ones
that had already finished, and the next launch searched the same range
again. Java now drives the loop and stores each result as it arrives,
which also lets the map update per tag and keeps one failure from taking
the rest of the batch with it.

Serialising calls into Python fixed the event-loop crash but turned it
into a backlog: the 60-second refresh kept firing during a long fetch, and
each tick blocked on the lock rather than being dropped, so a queue grew
by one a minute and then fired all at once. PYTHON_LOCK is now a
ReentrantLock exposing isBusy(), and the periodic path skips its turn - a
refresh that is minutes late has no value.

A fetch running longer than six seconds now shows a banner saying which
tag it is on and asking the user to keep the app open, because the work is
lost if they do not. Six seconds so an ordinary refresh never flashes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The entire 0.0.2 alignment path was dead code. There was a FILE_TYPE
constant, a case in the switch, a keyAlignmentRecords map, an
alignment_plist column and a Room migration to add it - but no entry for
KEY_ALIGNMENT_RECORD in MATCHERS, which getAllowedFileType is the only
thing that iterates. Every KeyAlignmentRecords entry therefore returned
null, was logged as an unexpected file, and was dropped.

Nothing failed. Imports just silently kept searching each tag's whole key
history, which is the ~50,000-index problem the feature exists to solve.
It survived because no real 0.0.2 zip has ever been produced, and the
on-device testing exercised the fallback probe, which works without a
record.

AppleZipImporterUtil had no tests at all. It now has four: a 0.0.2 zip
populates alignmentPlist; records key off the parent directory rather than
the record uuid, so two tags cannot get each other's; legacy 0.0.1 zips
still import with null; and a record for an unknown accessory is ignored.
Reverting the matcher fails two of them.

On the wizard side, three tests pin the other half of the contract:
KeyAlignmentRecords is in WHITELISTED_DIRS, make_output_path preserves the
<accessory>/<record> nesting and rewrites .record to .plist, and
decrypt_folder writes a decryptable record there. The path assertion
checks against a copy of the Android matcher, so if either side drifts a
test fails instead of records silently vanishing again.

Also fixes _create_plist, annotated as returning bytes while returning a
tuple, which accounted for five of the wizard's pyright errors, and drops
a duplicate pytest import that would have failed lint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
connectedAndroidTest against a hand-started emulator is unreliable in a
way that looks like a code problem: the Android Gradle Plugin holds its
ADB connection inside the Gradle daemon and reuses it across invocations,
so once the emulator's adb daemon goes stale the next run fails to install
or hangs. Recovering needs both an emulator restart and ./gradlew --stop,
because restarting only the emulator leaves the daemon holding the dead
bridge. A managed device is created and destroyed per run, so nothing
survives to go stale.

    ./gradlew :app:testEmulatorDebugAndroidTest

Verified from cold: 24/24 in 1m37s including downloading the API 34 ATD
system image, with no manual setup. testAllOnDevice now uses it, so it no
longer needs an emulator booted first. It is also the only form of this
that CI can run unattended.

The aosp-atd image has no Play Services, so a test that needs Maps would
have to move to a google image. Nothing does today.

scripts/run_instrumented_tests.sh stays as a fallback for running against
an emulator you already have open. It pins ANDROID_SERIAL so a run cannot
install to a physical phone, and restarts both halves if a run wedges.

AppCryptographyUtilTest asserted the app's entire AndroidKeyStore held
exactly one alias, which is only true on a device that has never signed
in - signing in leaves KEYSTORE_ALIAS_ACCOUNT behind permanently, so the
test passed once and failed forever after. It now asserts its own alias is
present, and deletes it before and after: keystore entries outlive the
process and the test run, so a crashed run would otherwise leave a key the
next run silently reused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces a hardcoded byline and the Developer Website button with a grid
of contributor avatars, each linking to their GitHub profile. It picks up
new contributors on its own, which is the point - a list nobody has to
remember to update is the only kind that stays correct.

Generated at build time by scripts/fetch_contributors.py into assets, and
refreshed weekly by a workflow that opens a PR when it changes. The app
reads the assets and never calls GitHub, which matters: the unauthenticated
API allows 60 requests an hour per IP and carrier-grade NAT puts a great
many users behind each one, so a runtime call would be rate limited in
practice. A token cannot be shipped to raise that, since anything in the
APK can be read back out. Bundling also avoids handing every user's IP to
GitHub when they open a screen with no other remote content - this app's
users are avoiding one company's location network, and quietly signing
them up to another's CDN is a poor trade.

Ordered by recency-weighted contribution volume rather than commit count:
each week's additions and deletions are log-compressed, so one vendored
dependency cannot outrank a year of real work, then decayed with a
52-week half-life. It is a heuristic and the script says so - churn is not
value, and a well-judged ten-line fix scores badly.

The grid uses a small FlowLayout that wraps on measured width rather than
a fixed column count, so it fills the space it has and only scrolls once
its rows no longer fit. Bots are filtered out; stale avatars are removed.

Also restructures the page from a bottom-pinned block overlapping a
full-height one into a single column, which is what lets the grid take the
leftover space without pushing the buttons off screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With nothing imported, My Devices was a blank page. The only way to import
is a menu on the map, which a first-time user has no reason to open, so
the screen was a dead end exactly when it mattered most. It now explains
what is needed, links the wiki page on producing the zip - the export
happens on a Mac, so there is no way to get one from inside the app - and
offers a button that hands the request back to MapsActivity rather than
duplicating the picker and the post-import fetch.

Long-pressing a device offers to remove it, saving a trip through the
detail page. The menu is anchored to the pressed row rather than shown as
a dialog, because the confirmation does not name the device. It reuses the
same markBeaconAsRemoved call, so rows are still hidden rather than
deleted and the location history survives a re-import.

The Settings account card also rendered with two empty text views and
filled them a few frames later, so the text appeared from nowhere and the
card resized under the reader. It now shows a pulsing skeleton of the same
size. SkeletonView animates whenever it is attached and visible and stops
when it is not, so no caller has to remember to cancel it - an animator
left running on a detached view leaks and quietly burns frames.

The byline becomes "by:" ahead of the contributor grid, and the Developer
Website string and property are removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tapping a marker scrolled to its card but left the marker where it was. The
same commit that dropped the z-index from card selection dropped it from
onMarkerClick, which used to call bringMarkerToTop before navigating. Tapping
the one visible marker of an overlapping pile therefore looked ignored.

The ordering now lives in MarkerFocus, tested on the JVM. It is small logic, but
it is the shape that already went missing once and stayed missing: nothing
throws when it is gone. The marker is still added, still correctly positioned,
still tappable - just underneath another one. The constants and the field it
used were even left behind unreferenced, so the code still looked present.

MarkerFocusTest asserts the calls actually happen, and pins the cases that would
otherwise be discovered on a phone:

- selecting lowers the previous marker before raising the new one
- reselecting the same tag issues nothing, since the card list settles on the
  same card repeatedly while swiping
- a tag whose marker does not exist yet does not unseat the raised one, which
  would leave the pile with nothing on top
- a rebuilt marker for the selected tag is built raised, because every refresh
  removes and re-adds them

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The card row has broken visually three times now - uneven heights, a clipped
shadow, the button row behind the navigation bar - and none of it throws. The
app runs, the data is right, it just looks wrong, so the only detector was
somebody noticing on a phone.

Inflates the real card layout into a container built the same way
activity_maps.xml builds the scroll row, and measures it. No map, no network,
no database, no Apple account.

- every card matches the tallest, whatever its address or name length
- WRAP_CONTENT is asserted to produce a ragged row, so if that ever stops being
  true the uniform-height test is passing for some other reason and says so
- neither the scroll row nor the scroll area clips, which is what preserves the
  elevation shadow

Inflation and measurement run on the main thread: the card holds a Material
CircularProgressIndicator whose drawable starts an animator and throws
"Animators may only be run on Looper threads" anywhere else. Assertions stay on
the test thread so a failure is reported rather than crashing the main looper.

33 instrumented tests now, all green on the managed device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The history screen showed one populated day and empty days behind it, for every
tag. Every request logged the same shape:

    range=[1786226400000, 1786312800000]  Got 8 raw reports  -> 8 after filtering
    range=[1786140000000, 1786226400000]  Got 8 raw reports  -> 0 after filtering
    range=[1786053600000, 1786140000000]  Got 8 raw reports  -> 0 after filtering

The same eight reports every time, filtered away to nothing. No error anywhere,
and nothing wrong on the Java side: the ranges it asked for were correct and
contiguous.

_fetchReportsForAccessory took start and end and never passed them to the fetch,
because there is nowhere to pass them. FindMy 0.9.x dropped the range parameters
0.7.6 had, and for a rolling-key accessory fetch_location_history delegates to
_fetch_accessory_reports(..., only_latest=True), which walks backwards from now
and returns at the first batch of keys that yields anything - about one day at
96 indices per day and 290 keys per request. Collateral from the 0.9.x
migration, not from the key alignment work.

The library does expose both halves needed to do it properly:

  * accessory.keys_between(start, end) yields (index, key) for exactly the
    window, primary and secondary, de-duplicated
  * fetch_location_history(list_of_keys) batches plain keys into one request and
    decrypts what comes back

So _fetchReportsInRange generates the window's keys and asks for those, then
feeds each report back into the accessory's alignment - the part the library
cannot do for us, since it is handed plain keys and does not know which index
each came from.

Bounded on purpose. Keys are chunked at 255 per request, and one ranged fetch
may make at most 8 - a day is roughly one. Alignment is established before
generating keys rather than after, the opposite order to the last-reports path:
there a probe was wasted work because only the latest report was wanted, here
the range is the whole point, so an unaligned accessory would otherwise yield
tens of thousands of keys for a single day. Without that cap a history screen
could quietly fire hundreds of requests at Apple, which is issue #30 arriving
through a different door.

10 tests covering the key range requested, batching, alignment updates, the
request cap, a failing batch, and the two library primitives the whole approach
rests on. The fake account asserts it is passed a list of keys, because passing
the accessory itself is precisely what caused this.

Also fixed three pre-existing long lines and an f-string with no placeholders in
main.py, which flake8 flags on any commit touching the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Today" showed 0 reports, then 16 for the same range a few seconds later. The
log says why:

    range=[1786226400000, 1786312800000]
    A chunk of the ranged fetch failed: ... aiohttp ... TimeoutError
    Ranged fetch searched 99 keys in 1 request(s)
    Got 0 raw reports  ->  0 reports after filtering

A day is a single request, so one network timeout emptied the whole day. Two
faults, both mine, introduced with the ranged fetch:

Nothing retried. Apple's endpoint times out often enough to hit by hand, and
with one request per day there is nothing else to fall back on. Requests are now
attempted twice, with a second's pause. Two, not more: this is a retry against a
rate-sensitive endpoint, and the whole reason the fetch is capped at 8 requests
is to stay quiet.

Worse, a total failure returned an empty list, which is indistinguishable from
Apple having no reports for that day. The screen said "0 reports" with complete
confidence about something it had failed to find out - the same silent-absence
shape as the zip disposal and the dropped setZIndex, freshly reintroduced. When
every request in a range fails, it now raises, and getReports skips the
accessory rather than reporting an absence it cannot vouch for. A partial
failure still returns what arrived, and logs that the range is incomplete.

Tests: a retried timeout, giving up after the retry rather than looping, a
wholly failed range being an error, and - the other side of it - a genuinely
empty range still being a valid answer rather than an error. The fake account
fails by chunk identity rather than call number, so retrying a doomed chunk
fails again instead of passing by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The history screen already has an error state - a warning icon, a message and a
Retry button in view_history_bottom_sheet.xml - and a failed day was never
reaching it.

Java's mapResults only raises when this module returns None. A dict that is
merely missing a beacon reads as "that beacon has no reports", so every
per-accessory failure was swallowed into a partial result and the call looked
successful. The screen then rendered a confident "0 reports" for a day it had
failed to fetch, with no error and no Retry, which is why the same day could
show 0 and then 16 a few seconds later.

getReports and getLastReports now count failures. If every accessory failed,
they return None so Java raises and the existing error state appears. A partial
result is still returned, because the accessories that did answer have fresh
reports and updated alignment worth persisting, and it logs that it is short.

Note this makes the previous commit's raise actually mean something: raising out
of _fetchReportsInRange only mattered if the layer above stopped turning it back
into an empty success.

Tests cover both directions, since the distinction is only worth anything if
both hold: every accessory failing is an error, some failing is a partial
result, no accessories at all is a valid empty answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Contributing section still implied there were none - the explicit line about
testing and CI/CD not following best practices went earlier in this branch, but
the "hackathony" framing was doing the same job by implication, and it is no
longer what a contributor would find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@parawanderer
parawanderer merged commit f2db0c4 into main Aug 9, 2026
8 checks passed
@pattontim

pattontim commented Aug 9, 2026 •

Copy link
Copy Markdown

I tried the debug release and the google map view is white but shows the control UI and google in the corner.
Will post logs when I get to a computer

Edit: tried 1d400ee

Just for awareness!

@parawanderer

parawanderer commented Aug 9, 2026 •

Copy link
Copy Markdown
Owner Author

Hey @pattontim so there is a proper working release build of this now (well it should be working) that you should be able to update to.

It should look like this:

Screenshot_20260809_233601_OpenTagViewer

This branch was previously deployed

1 inactive deployment
Android Build — c388b8d5 Deployed Aug 9, 2026 by parawanderer via Instrumented tests (emulator) #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.

Feature Request: Allow users to switch different map providers

3 participants