Find, ring and watch an accessory over direct BLE - #139
Conversation
2bd1379 to
6637e02
Compare
|
Marking this as work in progress — please hold off merging. Testing with two real third-party Find My accessories surfaced a reliability gap: an accessory's stored key alignment can drift ahead of its true index, and once that happens the 12h search margin alone doesn't recover it (one of the two needed a multi-day margin to be found at all, which isn't a fix, just a wider blind spot). Root cause: Working on a fix that restricts alignment corrections (both the BLE feedback path here and the existing network-fetch path) to primary-key matches only, and allows correcting downward when one disagrees with the stored value. Will follow up once it's tested end to end — a real AirTag test is planned for next week, which should also cover ground this branch hasn't (only third-party accessories tested so far). |
|
Agreed, holding. Confirming the network half from the pinned source so you don't have to re-derive it — for i in sorted(key_to_ind[key], reverse=True):
accessory.update_alignment(report.timestamp, i)
Note I pushed to your branch while you were writing this (rebase onto @parawanderer has an AirTag and an owner iPad that can be powered off for the separated case, so the AirTag gap is coverable here too if that's useful alongside your test next week. (Reply drafted via Claude Code, reviewed by me.) |
|
Quick note I wanted to drop here before I go all in on my own review as I am finishing up the other open items I still had for 1.1.0: so the app now optionally connects to iCloud. When it does fetch your tags from iCloud, if you enable the debug option in Settings, you'll see that on the "My Devices" device page of every AirTag it has the battery status as reported by iCloud on it. Now I think currently I only get the list from iCloud every 6 hours (I will probably tweak it because iPads update more frequently), but I'm curious if that matches the status you're seeing the tags report by bluetooth? 🤔 |
|
Good question. Two things worth knowing about what the Bluetooth side reports before comparing: The advertisement only carries a 2-bit battery field, so it is one of four coarse levels (full / medium / low / critically low), not a percentage. It's the same encoding FindMy.py's BATTERY_LEVEL map reads. So the comparison can only ever be "same bucket or not". On my two third-party tags there is nothing to compare against, which is itself the interesting result: both were imported via the iCloud account connection (not the zip import), and the iCloud record still reports battery level 0 ("not yet reported") for both. So for these tags the BLE reading is the only battery signal that exists at all. Presumably only Apple's own devices ever write that field for third-party accessories, or these vendors never report it. Your AirTags may well behave differently there, so your comparison would cover the case mine can't. More generally: for anyone without an Apple device around (this app's core audience, arguably), BLE is the only battery source there is, whatever iCloud's refresh interval. The design question that actually falls out of this is persistence: right now my build only shows the BLE value while it's live and lets it age out. Should a sighting's battery level be persisted and shown as "last known (from BLE, at time X)" until a fresher value arrives from whichever source? Curious what you think. The passive-watch build lives on my fork if you want to poke at it: https://github.com/ubrt/OpenTagViewer/tree/feature/nearby-tag-status (built on top of this PR's branch; not proposing it here, still iterating). |
046f3db to
ebffe4e
Compare
|
Coming off my sprint to implement the whole iCloud stuff here just to answer your question @ubrt (I will check your work tomorrow or as soon as I am able to with my devices). But basically I would say that: yes you should save that battery status information and probably the time it was recorded too. It's useful for the owner of the tag to have, imo. I would combine that storage with the persistence of the BLE scans as an alternative source of location history - on top of the apple FindMy network reports (to the extent that you fill the fields of the LocationReports with BLE info). Anyways, I highly recommend you rebase on |
Computes the accessory's current expected BLE MAC address(es) through the pinned FindMy.py fork's rolling-key derivation (main.py:currentMacAddresses, backed by the new RollingKeyPairSource.current_mac_addresses), scans for a match, and writes the DULT/FindMy/AirTag GATT play-sound characteristic - the same thing Find My itself does when a tag is close enough to reach, without going through Apple's network. New menu entry on DeviceInfoActivity. The GATT protocol details in ble/BleGattSoundTrigger.java - the service and characteristic UUIDs, the start opcodes, and the order the three protocols are tried in - are derived from AirGuard (Apache-2.0), verified against its AppleFindMy.kt and GoogleFindMyNetwork.kt. This repository is MIT, so the Apache-2.0 terms are recorded for the derived portion in a new NOTICE file rather than only in a Javadoc header. AirGuard ships no NOTICE of its own, so there is none to propagate. The three protocols are a fallback chain rather than belt-and-braces: not every accessory exposes the same characteristic, so relying on one alone misses devices. Cheap to keep - discoverServices() fetches the whole service table in one round trip and the three checks are local. Temporarily pins a personal FindMy.py fork (ubrt/FindMy.py) across all four places this repository pins it - see the comments at each - until the current_mac_addresses() addition has been offered upstream and lands in parawanderer's fork in turn. Verified: full JVM suite, the Python bridge suite, flake8, pyright, and on real hardware - see the branch's PR description for which accessories and in what state.
MapsActivity's "Ring" button was already there in the layout, wired to a no-op onClickRing - this fills it in rather than adding new UI. Toggling it starts AccessorySoundTrigger.playSoundContinuously (scan, trigger, pause, repeat via Rx repeatWhen) for that card's tag until tapped again or another tag's ring is started; the icon/label swap to a red stop glyph while running. BeaconInformation gained ownedBeaconAccessoryJson (mirroring the existing ownedBeaconPlistRaw), populated in BeaconDataParser, since MapsActivity's per-card data previously had no path to the accessory JSON the ble/ package needs - DeviceInfoActivity's one-shot trigger reads it from OwnedBeacon directly, but the map screen's BeaconInformation DTO didn't carry it. Updates TagCardLayoutTest, which had a test pinning the ring button as GONE from before this - "so that stops being true on purpose rather than by accident". That guard now flips: the button is shown at rest, plus new layout tests for the default label and for TagCardHelper's toggle/label behavior, run on the same aosp-atd managed device (no Maps involved). Requests BLE permission on tap, same as DeviceInfoActivity's one-shot trigger - missed on the first pass here, and invisible on a debug install that had already granted it via that other screen first. A fresh install (found by testing an actual release build) surfaced it: the ring button did nothing but log MISSING_PERMISSION on a loop, forever, with no dialog ever shown. Verified: full JVM suite (124 tests), instrumented layout suite (17 tests, including the new ring-button ones), installed and running - including the permission-request fix, verified end to end on a real release build after the bug surfaced there (two full scan/connect/trigger cycles, one of them after a retry).
AccessorySoundTrigger.playSound/playSoundContinuously now emit BleSoundTriggerUpdate items (SCANNING/CONNECTING/TRIGGERING, then one terminal DONE) instead of a single terminal result. Without this, both callers went silent for however long the scan and GATT handshake took, which reads as "nothing is happening" - especially the first time. Both screens now show the current phase (a replacing toast in DeviceInfoActivity, the ring button's own label on the map) instead of just a final result. The map's ring button also swaps its icon for a spinner for as long as an attempt is actually in flight (SCANNING/CONNECTING/TRIGGERING) - the label alone can sit on screen for several seconds with nothing else moving, which was mistaken for a stall rather than for work in progress. Also: BleGattSoundTrigger.trigger is retried up to 3 times (800ms apart) when it fails with a plain connection/write failure, not when no known sound service was found on the device - a retry cannot fix the latter, only the former is the kind of transient BLE flakiness a retry is for. Previously one dropped connection meant an immediate failure with no second attempt. BleAccessorySoundTrigger's three hardware-dependent seams (permission check, scanner, GATT trigger) are now constructor-injected instead of static calls to BlePermissions/NearbyAccessoryScanner/BleGattSoundTrigger, the same reasoning AppDependencies already uses for HardwareDescriber - those three need real Bluetooth to run, which a JVM test cannot arrange, but the orchestration logic around them (the permission gate, the retry count, what an empty candidate set or a scanner timeout maps to) does not and is now covered by BleAccessorySoundTriggerTest (11 tests, fakes only). The class is generic over the found-device type (<D>, fixed to BluetoothDevice in the real forRealBluetooth() factory) because the real Android class has no public constructor and this project has no Robolectric to fabricate one for tests - a plain String stands in instead. MapsActivity's handleContinuousPingUpdate now stops the loop outright on MISSING_PERMISSION or NO_CANDIDATE_MACS instead of looping on them forever - neither recovers by waiting and retrying, so continuing was pure battery burn with no chance of succeeding. Found next to the permission- request fix in the previous commit, but belongs here: this switch over phases is what this commit introduced. Verified: full JVM suite (135 tests, including the new suite), installed and running - two full scan/connect/trigger cycles on a real release build, one of them after a retry, plus the loading spinner confirmed visually on a subsequent release build.
The write itself is near-instant, so the label went scan -> connect -> Stop with no visible moment where it actually worked - which is what looked like a missing step. A successful DONE now shows ring_status_success for the whole pause before the next scan (roughly as long as an AirTag's chirp lasts); any other outcome still goes straight back to "Stop". Verified: full JVM suite, installed and running.
The BLE work needs RollingKeyPairSource.current_mac_addresses(), contributed by Ulrich Barrot (@ubrt) and merged as parawanderer/FindMy.py#1. This moves all four pin sites off the personal fork and onto 254a7624, which carries it. The same bump also brings the keychain-export work that landed on that branch meanwhile - including the fix for parawanderer#140, where a recovered peer was addressed by its escrow label instead of the bottle's id and every share came back unreadable. Two things the app was getting wrong against that API: A bare current_mac_addresses() searches with no margin, so an accessory whose true index has drifted below where alignment believes it is is simply absent from its own candidate set - no error, no match, "not found nearby" for a tag sitting on the desk. FindMy.py's own is_from takes 12 hours; so does this now. And the call discarded the indices the map exists to provide. A sighting is an observation of the same kind as a decrypted report, so it now feeds back through update_alignment and is persisted the same way, which collapses the next scan from a 12-hour range to three keys. Co-Authored-By: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ches recordAccessorySeen fed back whatever index currentMacAddresses paired an address with, primary or secondary. A secondary key covers 96 consecutive primary indices, so its index is only the first one a search happened to reach, not the true one - and update_alignment only refuses a move backwards. Fed that index, it can ratchet alignment past the true index in the wrong direction, permanently. Measured on a real accessory that drifted 114 indices (28.5 hours) ahead this way and then needed a multi-day margin just to be found at all. The fix moves what crosses the bridge from the key index to the raw address: recordAccessorySeen now re-derives the key at that address itself and only accepts a match through its primary key, where the index is unambiguous. BleSoundTriggerResult.matchedKeyIndex becomes matchedMac throughout, since only Python can tell a primary key from a secondary one from an address - this side of the bridge never could, regardless of what shape crossed it. A primary match also corrects downward, which update_alignment itself cannot do (it only ever moves forward, correct for a fetch's own forward search but not for a BLE match that can legitimately land below the stored alignment - proof the alignment had already drifted too far ahead). recordAccessorySeen writes the corrected index straight into the serialized accessory instead of going through update_alignment for this.
A run wedged on a single test for 25 minutes and nothing noticed. The watcher already had the answer - `watch` mode reports a log that stops growing after eight minutes - and I hand-rolled a sleep loop around `--once` instead, which waits for a terminal line and is blind to a run that never produces one. It then hit its own window and exited 0 printing nothing, which reads exactly like success. @parawanderer spotted it at 1h22m; the tool would have said so at 8 minutes. So the skill now shows that wrong loop by name and what it cost, with the rule underneath: watch progress, not just completion, and a watcher that can exit silently is not a watcher. diagnose_stall also gains the third hang. Its advice was "suspect the device, check logcat -b crash", which is wrong for this one: the process is alive, the crash buffer is empty, and an activity has finished with nothing to replace it, so Espresso's root picker retries on a thirty-second backoff forever. Both commands that identify it are in the message now - which test never finished, and whether RootViewPicker is spinning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed the fixes onto this branch and closed ubrt#1, so it is all in one place. Also: the red check here was not a failing test. Why CI was red
Worth knowing for next time: "tests are failing" and "the test sources will not compile" look identical from the checks list. Only the step list tells them apart. And a second problem the first one was hidingWith that fixed, the suite gets further and then hangs indefinitely on:
The signature is an activity finishing itself with nothing to replace it, so Espresso's root picker retries on a 30-second backoff forever:
This is not from my commits. Checked both ways on the managed device, same class in isolation:
So it is pre-existing here, and the compile error is why nobody has seen it: the suite has not reached that test in a while. There is no per-test timeout — it was removed deliberately, because it cost about three minutes on every run — so this does not fail, it simply never ends. The job's I have not touched it: it is in this branch's own area and @ubrt is better placed to say what that test expects to be on screen. Happy to dig in if that would help. What the commits areFour fixes @parawanderer hit while using the app, plus the compile fix and a tooling change:
Every new test was run against the unfixed code first and confirmed red. Four new strings across all ten locales. Not verified: the full suite end to end on this branch, because of the hang above. It was green at 658 tests before rebasing onto the 40 newer commits here. 🤖 Generated with Claude Code |
The instrumented suite hung indefinitely on ImportingAZipPutsTagsOnTheMapTest and failed TheWholeAppJourneyTest, and CI stopped at 423/687 and was killed by timeout-minutes at 45 minutes having reported nothing for the last eight. startWatchingForNearbyTags asks for BLUETOOTH_SCAN and BLUETOOTH_CONNECT as the map opens - deliberately, so the badges on the cards work without somebody pressing ring first - and its own comment says the system dialog pauses the activity. This fixture granted only the two location permissions, so the map opened behind that dialog, nothing was resumed, and Espresso's root picker span on a thirty-second backoff. grantLocationUpFront already existed for exactly this, for location, and its javadoc had already predicted this: "a new test that used the fixture and forgot the rule would hit the same six-minute wall". It is worse than six minutes now. There is no per-test timeout - removed on purpose, it cost about three minutes a run - so nothing stops it; the test does not fail, it never ends, and the job's own limit is what finally kills it. Taken from BlePermissions.required() rather than named again here, since which permissions those are depends on the API level. Naming them twice is how the copy that is wrong goes unnoticed. Not mine, and checked before saying so: the same class hangs identically on this branch with only the compile fix applied and none of my other commits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
stopScan raises IllegalStateException("BT Adapter is not turned ON") when
Bluetooth went off while the watch was running, and this call is in the
emitter's cancellable - so it runs during disposal, when there is no subscriber
left to receive a throw. RxJava hands it to the global error handler and the
process goes down.
Not an exotic path: turn Bluetooth off with the map open, then leave the
screen. MapsActivity.onPause -> stopWatchingForNearbyTags -> dispose -> crash.
It took the whole instrumented run with it, reported as "Instrumentation run
failed due to Process crashed" after 91 of 687 tests.
The restart a few lines above already catches exactly this, for the same
reason, and says so. The cancellable was the one place it did not. Nothing is
lost by swallowing it: the adapter turning off is what stops a scan, so there
is nothing left to stop.
startScan is deliberately not touched. It is guarded by the scanner == null
check that precedes it, and a throw there reaches the subscriber as onError
rather than killing the process.
Found by granting the Bluetooth permission to the test runner: without it the
watcher returned early and never scanned, so no test ever reached this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight classes reach the map without the map fixture, and the map now asks for Bluetooth as it opens. One of them carried a GrantPermissionRule listing exactly the permissions that used to be enough. Nothing was wrong with any of them: they were written before the app asked for one more thing, and a rule is per-class, so there was no single place to add it. An ungranted permission does not fail a test here, it hangs the suite: the system dialog pauses the activity, Espresso finds nothing resumed, and its root picker retries on a thirty-second backoff. There is no per-test timeout, so nothing stops it - the run does not fail, it never ends, and CI's timeout-minutes: 45 is what kills it. Safe because nothing in this source set tests a refusal - no test asserts that a permission is requested, rationalised or denied, checked before writing it. The class says so, and warns that a future test about refusal has to revoke what it is about in its own setup. Also documents the log-file race in the watcher skill: reusing one filename lets a Monitor read the previous run's log, match its BUILD line and report a verdict for a run that has not started. It read as a stale APK here, which it was not. Full instrumented suite on this branch: 687 tests, 0 failed, 22 skipped - the first time it has run to completion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Follow-up to my last comment, which said the hang was left for @ubrt. That is no longer true, and it turned out to be hiding a crash in the app. The suite now runs to completion on this branch — 687 tests, 0 failed, 22 skipped — for the first time. Three layers, each hiding the next: 1. The test sources did not compileFixed already. CI died at 1m38s having run nothing, so everything below was unreachable. 2. Then it hung, because the map opens behind a permission dialog
With no per-test timeout (removed on purpose — it cost about three minutes a run) nothing stops that. The test does not fail, it never ends. Granting it in the map fixture was not enough: eight classes reach the map without that fixture, one carrying a Checked before doing anything that broad: no test in the source set asserts that a permission is requested, rationalised or denied, so a blanket grant hides nothing. The class records that, and warns that a future test about refusal must revoke what it is about in its own setup. 3. And that revealed a crash
This is a real user-facing crash, not a test artefact. Turn Bluetooth off with the map open, then leave the screen. The tests could not see it because without the permission the watcher returned early and never scanned at all — so the missing grant was hiding it. The scan-restart a dozen lines above already catches exactly this and says why ("Bluetooth went away between the stop and the start"); the cancellable was the one place it did not. Nothing is lost by swallowing it — the adapter turning off is what stops a scan.
@ubrt — the crash fix is in your code, so please sanity-check my reasoning on it. I kept it to the narrowest change that matches what the restart path already does. Verified
🤖 Generated with Claude Code |
|
@ubrt Checked just now without reconnecting the AirTags to the app, and it seems like the Ring button still works. I'm not sure if the keys are guaranteed to rotate within a day (do you know?), but if they did then I will declare the previous issue I had to have been caused by my iPad not being turned off properly. Testing the whole app with the full UI after a rebuild is still open on my end. This was the old build before your latest commits. |
|
As far as I understand, the keys are supposed to rotate every 15 minutes. So if you’re perfectly synchronized with this pattern, the AirTag should always be reachable. Without a connection to the host device, the time on the tag will likely become increasingly out of sync over time. Deviations from this can also occur when the battery is replaced. |
Reported from a Samsung phone: "any button we put at the bottom of the page (or random text) is barely to not clickable", with a screenshot of the keychain unlock screen's Unlock button behind the gesture pill. The theme draws under a transparent navigation bar, so a screen that does not pad for it puts its last control where the system takes the touches. **One helper instead of two halves.** There was a top-only one and a bottom-only one, and the top was applied to seven screens while the bottom went to one - nothing about writing the first suggests you owe the second. insetForSystemBars does both, and the top-only version is deleted rather than left there to be called again. AppleLoginActivity and ErrorReportActivity had neither: the second was invisible to a search-and-replace precisely because it called nothing. **Two screens needed more than the root padded, and the tests found both.** FetchFromICloud padded its scroll view, and its back and Unlock buttons sit outside it, anchored to the activity - so the padding moved the text and left the buttons exactly where the report showed them. It pads the root now. HistoryView's retry button is in a bottom sheet, positioned by its behaviour rather than by any parent's padding; padding the screen does not reach it and padding the sheet's own content did not move it either, measured both ways. It is left as it was: the sheet is dragged, so the button is reachable the way a scrolled row is, and doing it properly means the behaviour's peek height. The bar in the tests is invented, and that is the point: the managed device reports a systemBars bottom inset of zero, so asking the real device proves nothing on CI and a geometric assertion would pass on any layout at all. Dispatching a synthetic 240px bar asks whether the screen would keep its buttons out of one, which has the same answer everywhere. Covers every activity in the manifest except the map, which draws edge to edge by design. The iCloud screen is checked in FetchFromICloudFlowTest, which already knows how to give it a session - it closes itself without one. Confirmed to fail without the fix: with the login screen's call removed it reports "nothing on this screen reserved the 240px navigation bar". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
One more from using the app on a phone, reported as: "any button we put at the bottom of the page (or random text) is barely to not clickable" — with a screenshot of the keychain unlock screen's Unlock button behind the gesture pill.
One helper instead of two halvesThere was a top-only inset helper and a bottom-only one. The top was applied to seven screens, the bottom to one — nothing about writing the first suggests you owe the second.
Two screens needed more than a padded root, and the tests found both
The bar in the tests is invented, and that is the pointThe managed device reports a Two assertions, because each misses what the other catches: nothing anchored ends inside the bar, and something on the screen reserved its height — otherwise a screen with an empty bottom passes while handling no insets at all, then breaks the day somebody adds a button. Covers every activity in the manifest except the map, which draws edge to edge by design. The iCloud screen is checked in Verified
🤖 Generated with Claude Code |
`[.statusCheckRollup[].status] | all(. == "COMPLETED")` is true for an empty list, so the loop announced "all checks finished" one line after a push, for a PR with zero checks. That reads exactly like a green build, which is the failure this skill exists to prevent - it says in bold that silence is not success, and the loop it recommends had the same hole. Two shapes produce an empty rollup and neither is an outcome: a commit whose runs GitHub has not created yet, and a fork PR waiting on "Approve and run", which sits there indefinitely and reports "no checks reported on the branch". The second is now documented with the command to approve it, because it looks like a broken CI config and is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@ubrt so far, so good. I'm thinking it might be nice to reuse some free-to-use battery icon for the battery state in the main UI in place of the current "Battery" text to make the main map page easier to parse. I'm sure there should be something like this available already for free reuse, with 4 battery states. WDYT? Also, I guess that if we merge your work into the app, the limitation will remain that when the tags are in their owner-connected state they will not be able to be locatable, right? Maybe it's worth typing up an issue summarising the state of that limitation in this repository, so somebody who is interested in investigating that further has a nice basis to start from. I suspect you might have a bit more context on this than me, so maybe you could have e.g. Claude Code summarise your knowledge on that in a new issue post here (and I can add anything I ran into over the last year with this topic below it - but really I haven't touched the Bluetooth all that much at all). I think that after merging your changes here, that would leave the app's limitations at:
|
|
Heads up on an overlap with #173, which is likely to merge first.
This PR does not touch Meanwhile #173 rewrites the history CSV (adds
Nothing to change here yet. Just flagging that once #173 lands, this PR owns adding This comment was written by Claude Code. |
|
I did two tests this week while keeping the AirTag offline, and both were successful. The signal was detected and the sound was triggered. In my case, there was a slight delay of around 30 seconds after I unwrapped the AirTag. It could be that the device uses a longer interval between pings when it has been disconnected from its host for an extended period of time. I’ll finish up the remaining work next weekend. There is currently a bug with the notifications when a tag hasn’t been seen for some time, the alert never stops in some cases. If there are no showstoppers on your side, we could merge this next week? |
|
Hi @ubrt, nice! For me I've been testing it around town when I remember and it's been looking good so far. I haven't found any bugs with your mechanism though I did not test the alert. Totally cool to merge this after you fix the bug you found and then release this as part of the 1.1.0 version of this app! |
Two things, both about the same value being read from the wrong place. **The long-fetch banner shows for tags that are perfectly aligned.** 60c1aee added SlowFirstFetch so the banner only appears when the key search is genuinely wide, and it asks KeyAlignmentPlist, which reads lastIndexObservationDate out of the export's KeyAlignmentRecord. That column is written by refreshFromImport and refreshFromAccount and by nothing else. It is frozen at import. The alignment that actually decides the search width lives in accessory_json: FindMy.py serialises alignment_date and alignment_index, and updateAccessoryJson writes the whole blob back after every fetch. So anybody whose export is more than seven days old sees "Locating your tags (x of y)" on every single refresh, however recently their tags updated. Reported from a phone on this branch with tags that updated today. KeyAlignmentPlist's own docstring says what it is for - "the one thing known about a tag before anybody has ever scanned for it" - and ScanOrder honours that, with a comment saying the record is "only ever consulted for a tag with no scan history". SlowFirstFetch was the one caller that did not. AccessoryAlignment reads both fields out of the accessory state, and SlowFirstFetch.laterOf takes whichever of the two timestamps is newer: the live one normally, the export's record before the first fetch, and a re-import's record when it is fresher than a stale blob. Jackson rather than org.json, because org.json lives in android.jar and the JVM test runtime stubs it - a test would read zero from a document saying otherwise and pass. Rule 13. **And the tag page now shows the alignment, in the debug panel.** Index and date, or a line saying none is stored and the next fetch will search from the pairing date. That is the first thing worth quoting in a report about a fetch that takes minutes or comes back empty, and until now there was no way to see it short of reading the database. Nine JVM tests. Three strings in ten locales via add_strings.py. Not verified locally: no Android SDK on this machine, so the build and the suites are CI's to run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # app/src/main/res/values-de/strings.xml # app/src/main/res/values-en/strings.xml # app/src/main/res/values-fr/strings.xml # app/src/main/res/values-ja/strings.xml # app/src/main/res/values-ko/strings.xml # app/src/main/res/values-nl/strings.xml # app/src/main/res/values-ru/strings.xml # app/src/main/res/values-zh-rCN/strings.xml # app/src/main/res/values-zh-rTW/strings.xml # app/src/main/res/values/strings.xml
The one case this app cannot handle: a tag that is with its owner, so it is not in the Offline Finding network and there is nothing on Apple's servers to fetch however far back anyone looks. Everything about it was scattered across two blog series, four papers, three GitHub threads and a FRIDA repository, and none of it says which parts are settled. **The finding worth the whole document** is stek29's, in FindMy.py parawanderer#88: the proof of concept everyone reaches for writes GATT to a characteristic that is not present on a real AirTag with its owner nearby, because it is the *unauthorised* sound command. Authorised ringing - the owner's own tag, sitting next to them - is a different protocol over L2CAP. Somebody starting from the obvious PoC would spend a week finding that out. That has a bearing on parawanderer#139, which plays a nearby accessory's sound over GATT, so it is said in the document rather than left to be noticed. Also collects: what a nearby tag actually broadcasts and how that differs from a separated one (primary key every 15 minutes, secondary key daily at 04:00); Adam Catley on the first six key bytes travelling as the BLE address; the WOOT'22 firmware work behind seemoo-lab/airtag and what its jailbreak requirement really is; and AirGuard, which malmeloo says already rings tags from Android - marked unverified, because that claim is the strongest lead here and it should not be taken on trust. Nothing was read out of rustpush, apple-private-apis or export-findmy, per the clean-room note in docs/findmy-export/README.md. stek29's public comments are quoted; his branches are not. Index row added per rule 10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>


Implements #17, and carries the rest of the BLE work with it.
Both branches live here at your suggestion, since debugging the Bluetooth side as a user is much easier when the signal strength and the other BLE details are on screen at the same time.
Ringing an accessory
DeviceInfoActivity: a "Play Sound Nearby" menu entry, one shot.MapsActivity: the Ring button on the tag card is a continuous ping toggle. Scan, trigger, pause, repeat until tapped again.Both show live progress: Scanning, Connecting, Sending, result.
Ringing is a repeating burst rather than a continuous tone. Tapping to sounding takes roughly five seconds, the chirp runs a few seconds, then there is a gap, so while walking you get a burst every several seconds rather than something to home in on. The gap is a single constant,
CONTINUOUS_PING_PAUSE_MS, currently 4 s.Rationale for the FindMy.py / Java split is in my issue comment.
Seeing a tag that is in the room
A passive scan runs while a screen is open. Under its own "Over Bluetooth" heading, the device screen shows when the tag was last heard, its signal strength, and the battery level the accessory broadcasts.
The battery reading is persisted per tag, with the time it was heard. Nothing is copied from Apple's own battery field: that value is stale or unset for exactly the users this is for, and would arrive presented as something the phone had heard.
Where the phone was when it heard a tag is written as a location report of its own, marked
localin aprovenancecolumn so it can never be confused with a decrypted one. The map draws it like any other position, which is what answers "you left it somewhere in this building".Listening while the app is closed
An opt-in foreground service, off by default, with a permanent notification. Without it the radio only runs while a screen is open, which keeps the app a display feature. With it the app records, which is why it is opt-in and why the setting says so in its own text.
With the service running, a tag that goes quiet while the phone moves on raises a left-behind alert. A targeted verification scan runs before alerting, so silence only has to be worth checking rather than having to prove anything on its own.
The alert is per tag and off by default, since most tags a person owns are put down on purpose. The wait before it fires and the sound it makes are configurable, and it plays on the alarm stream on repeat until the notification is swiped: a chime at notification volume from a pocket is the thing that gets missed.
Finding the tag: the key derivation
The addresses worth scanning for come from the stored key alignment, extrapolated forward at one index every fifteen minutes.
The candidate margin is 48 hours, derived rather than chosen. One secondary key covers 192 primary indices, so a sighting matched through one places the tag no more precisely than that.
A window too wide to derive is cut to its newest thousand indices. A tag advertising right now has been running, so its index tracks the clock and sits at the top of the window; the bottom belongs to a tag that was switched off for months and has nothing to match anyway.
A primary-key match corrects the alignment in either direction.
update_alignmentonly moves forward, which is right for a fetch and wrong for a BLE match: that comes from a wide, symmetric window and can legitimately land below where alignment believes the tag is, which is proof it has run ahead. Correcting that means writing the serialized state directly, withupdate_alignment's backward-time guard re-implemented so the bypass does not lose it. This is the piece I would rather push upstream than keep as a bypass.Only a primary match writes an alignment. A secondary key is reported at the first index the search reaches, so its index is a lower bound rather than a position. A secondary match therefore only raises the floor, upward, which can undershoot the truth but never overshoot it.
Each candidate address is paired with the index it came from, and a sighting hands that back as a hint, so confirming an alignment costs three key derivations at one index rather than the whole window again: 0.02 s against 1.02 s for the same answer.
Derived addresses are kept across launches, in a file per tag. An address is a pure function of the accessory's keys and an index, so a stored one can never be wrong, only incomplete, and a rebuild asks Python only for the part it is missing. The index is kept only for primary keys, which sit at one index forever; a secondary key's index depends on where the deriving range began, so it is stored as unknown rather than as a number that would later be believed.
WideningSearchlooks progressively further back for a tag nobody has heard: five hundred indices at a time, at most one tag a minute, never in the first two minutes after a scan starts, and only for tags not heard in the last ten. It stops when the tag turns up or when it reaches a hundred days. See the caveat below.What it costs
keys_betweende-duplicates, and secondary keys collapseHashMap<String, Match>The warm-up is the reason nothing expensive runs in the first two minutes after a scan starts, and the reason the derived addresses are kept: on a cold start with a populated store, a tag costs one index of derivation rather than several hundred.
Limits worth knowing
WideningSearchis not backed by an observation. It exists on the theory that a tag whose index has drifted far below the extrapolation would otherwise never be found. I have no measurement showing that happens to a tag that stayed powered: the index follows the tag's own clock rather than network contact, and the bounded slice already allows about ten days of slack. It is tested and it costs nothing for a tag that is heard, but I would not defend it as necessary.This branch therefore also carries the measurement that would settle it. When a fetch moves a tag's alignment, that is a real observation of where the tag was, and extrapolating the previous alignment forward to the same moment gives the drift directly. It is appended to a file in the app's external files directory rather than only logcat, since the reading is worth something as a series over weeks and logcat on a busy phone holds minutes.
Owner-nearby does not work. With the owner present, a 15 second scan sees the owner-present short form (
12 02, one observed at −49 dBm) and nothing derived from the key schedule matches it. It fails at detection, not at connect or write, which is consistent with stek29's note that authorised playback is a separate, L2CAP based mechanism. Worth knowing when testing: a tag sitting near an iPhone looks missing, and that has been mistaken for a bug more than once.Not tested: Google Find My Device accessories, third-party brands beyond the one below, and Android versions other than 17.
Attribution
The protocol constants and fallback order in
ble/BleGattSoundTrigger.javaderive from AirGuard (Apache-2.0). ANOTICEnames what is derived and from where. AirGuard ships no NOTICE of its own and its LICENSE has no copyright line filled in, so the file attributes the project rather than restating a notice its authors never wrote.Testing
Pixel 10 Pro, Android 17, release build, over several days of ordinary use.
Ringing runs through to
DONE (SUCCESS)on real AirTags and on a third-party FineTrack Mini Smart Finder. The passive scan, the alignment correction, the local position write and the background service have each been observed working on a device.Suites: 348 JVM, 269 Python bridge, 346 strings in each of the ten locale files, flake8 and pyright clean.
The instrumented suite has not been run against this state. It passed at 568 with 5 ignored and 0 failures on
aosp-atd, and the two migrations added here are additive, but I have not put a number behind that and will not imply one.Pin
The FindMy.py pin points at
parawanderer/FindMy.py@ddc7f234, so the blocker this PR opened with is resolved.