Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
419cb9a
Add "Play Sound Nearby": trigger an owned accessory over direct BLE
ubrt Aug 22, 2026
972d9f0
Add continuous ping: repeated scan+trigger, toggled from the map itself
ubrt Aug 22, 2026
2324722
Turn playSound into a progress stream, and retry a flaky GATT connection
ubrt Aug 22, 2026
c594cc4
Show "Ringing!" on a successful continuous-ping cycle, not just "Stop"
ubrt Aug 22, 2026
1d7e37b
Pin the merged FindMy.py, and use the key index it now hands back
parawanderer Aug 23, 2026
e4bbfcc
Restrict alignment corrections from a BLE sighting to primary-key mat…
ubrt Aug 23, 2026
056d758
Refuse a sighting dated before the stored alignment
ubrt Aug 23, 2026
173dd10
Find a tag whose alignment has drifted, and check one index when we k…
ubrt Aug 25, 2026
0cca12b
Merge main, keeping both sides of every strings.xml
parawanderer Aug 26, 2026
e88819f
Say what the derivation now does, rather than what it was going to do
parawanderer Aug 26, 2026
0d6f9ec
Hear the user's own tags while a screen is open, and read their battery
ubrt Aug 23, 2026
7bb30c9
Say when a tag is audible, and show the battery it reports itself
ubrt Aug 23, 2026
da99f94
Ask for BLE permission on opening the map, and show a rough distance
ubrt Aug 23, 2026
17e63b3
Replace the distance estimate with a signal strength word
ubrt Aug 23, 2026
36c1cfe
Show the signal strength as five dots instead of a word
ubrt Aug 23, 2026
b42f23d
Show how long ago the map card's nearby sighting was heard
ubrt Aug 23, 2026
1206d45
Tick the nearby cards once a second so "seconds ago" actually counts
ubrt Aug 23, 2026
ab6bf23
Replace the seconds counter with a lit pulse dot
ubrt Aug 23, 2026
cdc0c65
Feed passive sightings into alignment self-correction too
ubrt Aug 23, 2026
c2c339c
Reread beacons and restart the watch after a self-correction lands
ubrt Aug 23, 2026
b0f536e
Revert the reread-and-restart from the previous commit
ubrt Aug 23, 2026
0279e3d
Scan at SCAN_MODE_BALANCED instead of SCAN_MODE_LOW_POWER
ubrt Aug 23, 2026
efee760
Fill the two nearby strings into every locale
ubrt Aug 23, 2026
8bc6f28
Rebuild the nearby index mid-watch, and publish it safely across threads
ubrt Aug 23, 2026
040dae5
Start the nearby watch once the beacons have actually loaded
ubrt Aug 23, 2026
2007951
Address the remaining review findings on the nearby feature
ubrt Aug 23, 2026
21e7ac7
Filter the nearby scan in hardware, and restart it before Android mut…
ubrt Aug 23, 2026
2ef5b89
Read only the address side of the resolved candidate map
ubrt Aug 23, 2026
fdbd536
Match NearbyTagIndexTest to the Map-returning resolver
ubrt Aug 23, 2026
e81cc2f
Keep what a tag last said about itself over Bluetooth
ubrt Aug 24, 2026
29f23c8
Feed the passive scan's battery reading into the same write
ubrt Aug 24, 2026
e591aa8
Give what the radio heard its own section on the device screen
ubrt Aug 24, 2026
6f07572
Say "just now" rather than "0 minutes ago" on a fresh sighting
ubrt Aug 24, 2026
d2da7b3
Stop one unalignable accessory from costing every tag its scan
ubrt Aug 25, 2026
cef56fb
Show the raw BLE status byte in the debug panel
ubrt Aug 25, 2026
7be609d
Say that the null-resolver guards are latent, not observed
ubrt Aug 25, 2026
bbbb630
Record where the phone was when it heard a tag
ubrt Aug 25, 2026
3dfa983
Check one key index instead of re-deriving the window
ubrt Aug 25, 2026
217836c
Keep listening while the app is closed, and say when a tag is left be…
ubrt Aug 25, 2026
637314b
Let the user switch off the alert per tag, and make the alert loud
ubrt Aug 25, 2026
1471ecc
Show the left-behind switch at all, and default it to off
ubrt Aug 25, 2026
06d62aa
Re-read which tags want an alert, instead of once at startup
ubrt Aug 25, 2026
74e3a1c
Let the user choose the wait and the alarm sound
ubrt Aug 27, 2026
205cf47
Put a tag on the map where this phone heard it, not where iCloud last…
ubrt Aug 28, 2026
a79a518
Say what deriving a candidate window costs, in indices and seconds
ubrt Aug 29, 2026
86ab833
Let a caller ask what range to search and derive it a piece at a time
ubrt Aug 29, 2026
67f9db8
Keep the addresses derived for a tag instead of deriving them every l…
ubrt Aug 29, 2026
aade994
Look further back for a tag nobody has heard, a chunk at a time
ubrt Aug 29, 2026
adacb16
Keep a primary key's index in the store, since that one does not move
ubrt Aug 29, 2026
06fc60f
Measure how far the extrapolation runs from where a tag actually is
ubrt Aug 29, 2026
78fe5fc
Put the ring button on the same pitch as the other three
parawanderer Aug 29, 2026
60c1aee
Only warn about a long fetch when the fetch is going to be long
parawanderer Aug 29, 2026
c4d5ba7
Stop the iCloud offer coming back, and say so when the connection is …
parawanderer Aug 29, 2026
bcb7769
Tell a keystore key that has gone from data that will not open with i…
parawanderer Aug 29, 2026
390416a
Match the sighting test to the report-returning recorder
parawanderer Aug 30, 2026
6641413
Make a stalled run say so, instead of looking like a slow one
parawanderer Aug 30, 2026
65c4276
Grant the Bluetooth permissions before the map opens, like location
parawanderer Aug 30, 2026
fe30f46
Stop a scan the adapter already ended, instead of crashing on it
parawanderer Aug 30, 2026
d71caf4
Grant the app's permissions once, in the runner, not per test class
parawanderer Aug 30, 2026
ab17d7f
Keep every screen's buttons out of the navigation bar
parawanderer Aug 30, 2026
2e944ac
Stop the PR watcher calling an empty check list a pass
parawanderer Aug 30, 2026
77aee3e
Read the alignment a fetch left behind, not the one the export shipped
parawanderer Sep 3, 2026
b1495b6
Merge remote-tracking branch 'origin/main' into feature/beep-nearby-tag
parawanderer Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .claude/skills/watch-gradle-tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,48 @@ python .claude/skills/watch-gradle-tests/watch_tests.py tmp/run.log
Each line it prints is one event: `FAILED <Class>.<method>` as each failure appears,
`STALLED …` if the log stops growing, and `FINISHED` + `VERDICT` at the end.

### Give every run its own log file

Reusing one name races the watcher against the run that is starting. Arm a Monitor while the
previous run's log is still on disk and it reads *that* — matches its `BUILD` line, prints its
verdict, and reports a finished run that has not started. The XML age in `VERDICT` is the only
hint, and "2 min old" looks perfectly current.

That cost a wrong conclusion here: a stale verdict was read as the new run's, its crash trace
pointed at a line number the fix had already moved, and the obvious inference — "the APK did
not rebuild" — was wrong twice over.

```bash
log=tmp/run-$(date +%H%M%S).log
./gradlew :app:testEmulatorDebugAndroidTest --console=plain > "$log" 2>&1 &
python .claude/skills/watch-gradle-tests/watch_tests.py "$log"
```

### Never wrap `--once` in your own sleep loop

That is the shape step 3 exists to replace, and it looks close enough to right to pass review:

```bash
# WRONG - and this exact loop cost 1h22m
for i in $(seq 1 110); do
grep -qE "BUILD SUCCESSFUL|BUILD FAILED" tmp/run.log && { ...--once; break; }
sleep 20
done
```

It waits for a **terminal line** and nothing else, so it is blind to the run stopping without
one — which is the failure worth catching. A suite hung 25 minutes on a single test produced no
new output and no verdict, so the loop sat silent, then hit its own limit and exited **0 with no
output at all**: indistinguishable from success. Meanwhile `watch` mode would have said
`STALLED no output for 8 min, at 424/687` seventeen minutes earlier.

Two rules follow, and they are the same rule twice:

- **Watch progress, not just completion.** "Still running" and "wedged" look identical unless
something is measuring the gap between outputs.
- **A watcher that can exit silently is not a watcher.** If yours can end without printing,
make the last thing it does print where the run got to.

## Why this exists

Three hand-written monitors in one afternoon each matched **nothing**, and each looked like a
Expand Down
9 changes: 9 additions & 0 deletions .claude/skills/watch-gradle-tests/watch_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@ def diagnose_stall(log: str, age: float) -> list[str]:
elif done:
lines.append("STALLED tests had been running, so suspect the device rather than the "
"lock: adb logcat -b crash")
# **A live process with nothing resumed is the third hang, and it is not a crash.**
# An activity finished itself - a session that would not restore, or a started activity
# that a test had stubbed - and Espresso's root picker then retries on a 30-second
# backoff forever, printing only "No activity currently resumed". The crash buffer is
# empty and the process is alive, so both of the checks above say nothing is wrong.
lines.append("STALLED if the crash buffer is empty, ask what is on screen: "
"adb logcat -d -s TestRunner:I | tail -3 (which test never finished) and "
"adb logcat -d | grep RootViewPicker (an activity finished and nothing "
"replaced it)")
return lines


Expand Down
18 changes: 17 additions & 1 deletion .claude/skills/watch-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ while true; do
break
fi
if [ "$(gh pr view "$PR" --json statusCheckRollup --jq \
'[.statusCheckRollup[].status] | all(. == "COMPLETED")' 2>/dev/null)" = "true" ]; then
'[.statusCheckRollup[].status] | length > 0 and all(. == "COMPLETED")' 2>/dev/null)" = "true" ]; then
echo "PR #$PR: all $total checks finished"
break
fi
Expand All @@ -112,6 +112,22 @@ Why it is shaped this way:
through a red build, and silence reads as "still running".
- **`2>/dev/null` on the `gh` calls, but no `|| continue`.** A transient API failure yields an
empty result and the loop tries again; it must not be able to exit quietly.
- **`length > 0 and` before the `all`, because `all` on an empty list is `true`.** Without it the
loop declares victory the instant the rollup is empty — which is exactly what a freshly pushed
commit looks like before GitHub has created its runs, and what a fork PR waiting on
*"Approve and run"* looks like indefinitely. It printed `all checks finished` for a PR with
**zero** checks, one line after a push, and that reads identically to a green build.

**A fork PR can sit at `action_required` forever, and that is not a failure state.** GitHub gates
workflow runs on PRs from forks, so `gh run list` shows `completed / action_required` and
`gh pr checks` says *"no checks reported"*. Nothing is wrong and nothing will happen until a
maintainer approves:

```bash
gh run list --limit 5 --json databaseId,headSha,conclusion \
--jq '.[] | select(.conclusion=="action_required") | .databaseId'
gh api -X POST repos/<owner>/<repo>/actions/runs/<id>/approve
```

## When it lands

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ tmp/
# stripped: device names ("<person>'s iPad"), record UUIDs, key material. Committing it would
# publish, in a notebook, exactly what was redacted out of the plists next to it.
scripts/plist_explorer.ipynb

# Local throwaway signing key for release builds. Never a real one.
*.jks
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ through [FindMy.py](https://github.com/malmeloo/FindMy.py).
`app/src/main/python/` is packaged into the APK by Chaquopy. Nothing in it may import
Android or Java types — that is what makes it testable on plain CPython.

### It ships as an APK, and there is no Play Store listing

There is no listing and none is planned. Releases are GitHub releases; people sideload them.

**So "Play Store policy requires it" is never a reason for anything here**, and an argument
leaning on it has nothing behind it. That cuts in a direction people find surprising: the
manifest's declarations, the permission flags and the data-safety claims in comments still have
to be *true* — because Android acts on them and because people read this source — not because a
reviewer will check. Nobody is going to check. That is the point.

Do not build for a listing that does not exist.

---

## Rules
Expand Down
48 changes: 48 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
OpenTagViewer
Copyright (c) parawanderer and contributors

This product is licensed under the MIT License (see LICENSE.TXT), and includes
work derived from third-party software under other licences, listed below.

--------------------------------------------------------------------------------

AirGuard (https://github.com/seemoo-lab/AirGuard)
by the Secure Mobile Networking Lab (SEEMOO), TU Darmstadt
Licensed under the Apache License, Version 2.0

(AirGuard's LICENSE is the unmodified Apache-2.0 text and declares no
copyright line of its own; this attributes the project rather than restating
a notice its authors did not write.)

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

What is derived, and where it lives:

app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java

The Bluetooth LE "play sound" protocol details for Find My / DULT
accessories: the service and characteristic UUIDs, the start opcodes, and
the order the three protocols (DULT, the generic Find My "fd44" service,
and the AirTag-specific service) are tried in, along with the
enable-notifications-then-write sequence each one needs.

Taken from AirGuard's
app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/AppleFindMy.kt
and cross-checked against
app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/GoogleFindMyNetwork.kt

The surrounding implementation - the RxJava3 state machine, the scan,
the retry policy and the app's own UI - is not derived from AirGuard.

Apache-2.0 terms continue to apply to the derived portion above. AirGuard
ships no NOTICE file of its own, so there is none to propagate here.
19 changes: 14 additions & 5 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,10 @@ android {
// field exists in both variants so code reading it compiles in both.
buildConfigField("String", "BUILD_COMMIT", "null")

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Grants the app's runtime permissions before the first test - see the class, which
// explains why an ungranted one hangs the suite rather than failing a test.
testInstrumentationRunner =
"dev.wander.android.opentagviewer.GrantWhatTheAppAsksForRunner"

// **Do not add `timeout_msec` here.** It works - a hanging test fails at the cap with
// its own name - but AndroidJUnitRunner pays for it per test, not per hang: with it set
Expand Down Expand Up @@ -569,13 +572,19 @@ dependencies {
* talk to a device - so the caching this disables was never doing anything. Gradle's own error
* suggests exactly this.
*
* The managed-device task is untouched, because `managedDevice/` is not the directory Studio
* watches.
* **This used to exempt the managed-device task**, on the reasoning that `managedDevice/` is not
* the directory Studio watches. That was wrong: `testEmulatorDebugAndroidTest` failed with the
* identical `Failed to create MD5 hash for file content`, seven seconds in, having run nothing.
* Both tasks write results Studio may be holding, and neither can ever be up to date, so both
* are untracked now.
*/
tasks.matching { it.name.startsWith("connected") && it.name.endsWith("AndroidTest") }
tasks.matching {
(it.name.startsWith("connected") || it.name.startsWith("testEmulator"))
&& it.name.endsWith("AndroidTest")
}
.configureEach {
doNotTrackState(
"Android Studio holds the connected results directory open on Windows, and an" +
"Android Studio holds the instrumented results directories open on Windows, and an" +
" instrumented run is never up to date regardless.",
)
}
Loading
Loading