Merge SadGare's fork: FindMy 0.9.x, map providers, and working key alignment - #49
Merged
Merged
Conversation
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>
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 16:15 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 16:15 — with
GitHub Actions
Inactive
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>
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 16:28 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 16:28 — with
GitHub Actions
Inactive
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>
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 16:49 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 16:49 — with
GitHub Actions
Inactive
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>
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 17:11 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 17:11 — with
GitHub Actions
Inactive
"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>
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 17:21 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 17:21 — with
GitHub Actions
Inactive
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>
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 17:26 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 17:26 — with
GitHub Actions
Inactive
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
temporarily deployed
to
Android Build
August 9, 2026 17:33 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 17:33 — with
GitHub Actions
Inactive
6 tasks
This was referenced Aug 9, 2026
|
I tried the debug release and the google map view is white but shows the control UI and google in the corner. Edit: tried 1d400ee Just for awareness! |
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:
|
This branch was previously deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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
29a15e4962ee6aCherry-picked rather than squashed into my own commits, so
git log --authorstill finds them.One commit from the fork is deliberately excluded. Gare wrote the
Migration(1,2)and the lazyaccessory_jsonbackfill correctly in53c61d7.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_reportsno longer exist, andLocationReport.published_at/.descriptionare gone. The client-side filtering andgetattr(report, "description", "")are correct.Key alignment — the substantial fix
Without a
KeyAlignmentRecord,FindMyAccessorystarts 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:
Same nesting and same BeaconStore key as
BeaconNamingRecord— it was excluded only byWHITELISTED_DIRS. Now wired end to end: the wizard exports it (format0.0.2), the importer parses it, migration2 → 3persists it, and it reachesFindMyAccessory.from_plist(plist, key_alignment_plist).Writing the tests found that this path was entirely dead code.
AppleZipImporterUtilhad the enum constant, the switch case, the map, the column and the migration — but no entry inMATCHERS, the only thinggetAllowedFileTypeiterates. 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.pyfetches the latest location instead of the history when the window is wide — one traversal, still narrows it. Verified live:Other fixes found by running it
AppCryptographyUtilTestasserted the whole keystore held one alias, so it passed once and failed forever after on any device that had signed in.Also in here
./gradlew :app:testEmulatorDebugAndroidTest, 24/24 from cold in 1m37s with nothing booted firstscripts/add_strings.py) with a--checkthat found 8 strings missing across 7 localesscripts/**added to the path filters, since the build already shelled out to itAdded 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./usr/bin/gitand/usr/bin/python3whose only job is to trigger the installer, so "is it on PATH" answers yes long before either works.Tk()window. The Command Line Tools Python links the system Tk, which aborts duringTk_InitwithAbort trap: 6and no traceback if it decides the OS is too old —import tkintersucceeds right up until it doesn't. Falls back to printing the CLI route, which needs no GUI at all.MACOS_VER: tuple[int, int] | None— 3.10 syntax at module level. Fixed withfrom __future__ import annotations, and 3.9 added to the CI matrix so it cannot regress.TESTING.md→CONTRIBUTING.md(viagit mv, history kept), with setup, every test suite, which Python each tree targets, and the macOS bootstrap.ghso an agent can read its own CI failures instead of guessing.Exporter versioning
VERSIONinpython/main/wizard.pyis the only place the exporter's version is written, and it is stamped into every export asvia: 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. Taggingmacos-exporter-v1.0.5against a tree still saying1.0.4would have shipped a build calling itself 1.0.4 everywhere a user can see, andpython/README.mdhad already drifted to a version the source never declared.scripts/exporter_version.py --tag <tag>now checks the two agree, intest-release-version, before either PyInstaller build starts. Both build jobs take the version from that job's output, which also removes the two copiedawk/cutblocks 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.VERSIONbumped 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.
onImportFilePickedforked into anObservable.zip: one branch fetching reports, the other parsing metadata.zipcompletes 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, asMarker.setZIndex, and was dropped when markers moved behindIMapProvider—MapPolylinekept azIndex,MapMarkernever gained one. The constants and thelastFocusedMarkerfield survived as unreferenced fossils, so the code still looked present. Restored for both card selection and marker taps, andMapMarkernow 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 inonNext, 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.
_fetchReportsForAccessorytookstartandendand 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 accessoryfetch_location_historydelegates 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 onaccessory.keys_between(start, end)plusfetch_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
aiohttptimeout emptied it. Requests are retried once now. More importantly, a wholly failed range used to return[], andmapResultsonly raises when Python returnsNone— 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 inview_history_bottom_sheet.xml. Total failure now returnsNone; 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
hoursToGoBackcould 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:
RxFlowsMarkerFocusRefreshPolicyBeaconLocationHistoryLongFetchBannerStateTagCardLayoutTesttest_main_ranged_fetch.pyThree of them are deliberately paired with an inverse assertion, because a guard that can only pass in one direction is not a guard:
wrapContentIsWhatMadeTheRowRaggedasserts 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 inonCreate, 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
assembleDebuggreen; 72 JVM, 33 instrumented, 43 Chaquopy bridge, 39 tooling, 29 wizard testsmaincannot exportKeyAlignmentRecordsat 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.8 held + 1 fetched = 8), and no app-level warnings or errors anywhere in logcatbootstrap_macos.sh, against realcom.apple.icloud.searchpartyddataKeyAlignmentRecordsexists on real macOS with the expected structureNot verified — please don't assume otherwise
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.