From 419cb9ae80f8fd2e8ee521ccede6da7c49ae8d8c Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sat, 22 Aug 2026 12:53:37 +0200
Subject: [PATCH 01/61] Add "Play Sound Nearby": trigger an owned accessory
over direct BLE
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.
---
NOTICE | 48 ++
app/src/main/AndroidManifest.xml | 12 +
.../opentagviewer/DeviceInfoActivity.java | 107 +++-
.../ble/AccessorySoundTrigger.java | 26 +
.../ble/BleAccessoryMatcher.java | 35 ++
.../ble/BleAccessorySoundTrigger.java | 68 +++
.../ble/BleGattSoundTrigger.java | 230 ++++++++
.../opentagviewer/ble/BlePermissions.java | 50 ++
.../ble/BleSoundTriggerResult.java | 17 +
.../ble/BleSoundTriggerStatus.java | 22 +
.../ble/NearbyAccessoryScanner.java | 94 ++++
.../python/AccessoryMacResolver.java | 28 +
.../opentagviewer/python/AppDependencies.java | 505 ++++++++++--------
.../python/ChaquopyAccessoryMacResolver.java | 56 ++
app/src/main/python/main.py | 24 +
app/src/main/res/menu/device_info_menu.xml | 4 +
app/src/main/res/values-de/strings.xml | 8 +
app/src/main/res/values-en/strings.xml | 8 +
app/src/main/res/values-fr/strings.xml | 8 +
app/src/main/res/values-ja/strings.xml | 8 +
app/src/main/res/values-ko/strings.xml | 8 +
app/src/main/res/values-nl/strings.xml | 8 +
app/src/main/res/values-ru/strings.xml | 8 +
app/src/main/res/values-zh-rCN/strings.xml | 8 +
app/src/main/res/values-zh-rTW/strings.xml | 8 +
app/src/main/res/values/strings.xml | 8 +
.../ble/BleAccessoryMatcherTest.java | 52 ++
app/src/test/python/test_main.py | 40 ++
28 files changed, 1265 insertions(+), 233 deletions(-)
create mode 100644 NOTICE
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessoryMatcher.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/BlePermissions.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerStatus.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyAccessoryScanner.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java
create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessoryMatcherTest.java
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 00000000..9610b0e3
--- /dev/null
+++ b/NOTICE
@@ -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.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 255ad158..7672151f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,18 @@
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
+
+
+
+
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index de4b5cde..685ab4fb 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -31,6 +31,7 @@
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.annotation.Nullable;
+import androidx.core.app.ActivityCompat;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.databinding.DataBindingUtil;
@@ -48,6 +49,8 @@
import java.util.Objects;
import java.util.Optional;
+import dev.wander.android.opentagviewer.ble.BlePermissions;
+import dev.wander.android.opentagviewer.ble.BleSoundTriggerResult;
import dev.wander.android.opentagviewer.data.model.BeaconInformation;
import dev.wander.android.opentagviewer.data.model.UserMapCameraPosition;
import dev.wander.android.opentagviewer.databinding.ActivityDeviceInfoBinding;
@@ -82,9 +85,12 @@
import io.reactivex.rxjava3.schedulers.Schedulers;
import io.reactivex.rxjava3.annotations.NonNull;
-public class DeviceInfoActivity extends AppCompatActivity {
+public class DeviceInfoActivity extends AppCompatActivity
+ implements ActivityCompat.OnRequestPermissionsResultCallback {
private static final String TAG = DeviceInfoActivity.class.getSimpleName();
+ private static final int PERMISSION_REQUEST_PLAY_SOUND_NEARBY = 1001;
+
private static final double DEFAULT_LONGITUDE = 0d;
private static final double DEFAULT_LATITUDE = 0d;
private static final float DEFAULT_ZOOM = 16.0f;
@@ -123,6 +129,10 @@ public class DeviceInfoActivity extends AppCompatActivity {
/** The in-flight write to the account, so leaving the screen does not land on dead views. */
private Disposable accountRename;
+ /** The in-flight BLE scan/GATT trigger, so leaving the screen stops it rather than
+ * leaving a scan running or a result landing on dead views. */
+ private Disposable playSoundNearby;
+
private boolean hasNameChanges = false;
@Override
@@ -590,9 +600,102 @@ protected void onDestroy() {
if (this.accountRename != null && !this.accountRename.isDisposed()) {
this.accountRename.dispose();
}
+ // Here disposing does cancel the underlying work - see BleGattSoundTrigger.trigger's
+ // cancellable, which closes the GATT connection rather than leaving it dangling.
+ if (this.playSoundNearby != null && !this.playSoundNearby.isDisposed()) {
+ this.playSoundNearby.dispose();
+ }
super.onDestroy();
}
+ /**
+ * Ask to play this accessory's sound directly over Bluetooth, without going through Apple's
+ * Find My network - see {@code dev.wander.android.opentagviewer.ble}. Only reachable while
+ * the accessory is close enough to answer a BLE scan, unlike the network-based search this
+ * screen otherwise relies on.
+ */
+ private void onClickPlaySoundNearby() {
+ if (!BlePermissions.granted(this)) {
+ Log.d(TAG, "Requesting BLE permission(s) before playing sound nearby");
+ ActivityCompat.requestPermissions(
+ this, BlePermissions.required(), PERMISSION_REQUEST_PLAY_SOUND_NEARBY);
+ return;
+ }
+ this.startPlaySoundNearby();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(
+ final int requestCode, @androidx.annotation.NonNull final String[] permissions,
+ @androidx.annotation.NonNull final int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode != PERMISSION_REQUEST_PLAY_SOUND_NEARBY) return;
+
+ // Re-checked against the same BlePermissions.granted this action gates on elsewhere,
+ // rather than reading grantResults directly - one place decides what "enough" means,
+ // matching the reasoning in BlePermissions' own class doc.
+ if (BlePermissions.granted(this)) {
+ Log.i(TAG, "BLE permission granted; playing sound nearby for beaconId=" + this.beaconId);
+ this.startPlaySoundNearby();
+ } else {
+ Log.i(TAG, "BLE permission refused; not playing sound nearby for beaconId=" + this.beaconId);
+ Toast.makeText(this, R.string.play_sound_permission_denied, LENGTH_LONG).show();
+ }
+ }
+
+ private void startPlaySoundNearby() {
+ final String accessoryJson = this.beaconData.getOwnedBeaconInfo().accessoryJson;
+
+ Toast.makeText(this, R.string.play_sound_searching, LENGTH_LONG).show();
+
+ if (this.playSoundNearby != null && !this.playSoundNearby.isDisposed()) {
+ this.playSoundNearby.dispose();
+ }
+
+ this.playSoundNearby = AppDependencies.accessorySoundTrigger()
+ .playSound(this.getApplicationContext(), accessoryJson)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ this::showPlaySoundResult,
+ error -> {
+ // AccessorySoundTrigger's contract is to never error a failure onto
+ // this path - see its interface doc - so reaching here means a bug
+ // in that contract, not an ordinary "not found" or "no permission".
+ Log.e(TAG, "Unexpected error playing sound for beaconId="
+ + this.beaconId, error);
+ Toast.makeText(this, R.string.play_sound_failed, LENGTH_LONG).show();
+ });
+ }
+
+ private void showPlaySoundResult(final BleSoundTriggerResult result) {
+ Log.d(TAG, "Play sound result for beaconId=" + this.beaconId + ": " + result.getStatus()
+ + (result.getMessage() == null ? "" : " (" + result.getMessage() + ")"));
+
+ final int messageRes;
+ switch (result.getStatus()) {
+ case SUCCESS:
+ messageRes = R.string.play_sound_success;
+ break;
+ case NOT_NEARBY:
+ messageRes = R.string.play_sound_not_nearby;
+ break;
+ case NO_SOUND_SERVICE:
+ messageRes = R.string.play_sound_no_sound_service;
+ break;
+ case NO_CANDIDATE_MACS:
+ messageRes = R.string.play_sound_no_candidate_macs;
+ break;
+ case MISSING_PERMISSION:
+ messageRes = R.string.play_sound_permission_denied;
+ break;
+ case FAILED:
+ default:
+ messageRes = R.string.play_sound_failed;
+ break;
+ }
+ Toast.makeText(this, messageRes, LENGTH_LONG).show();
+ }
+
/**
* The best description available without asking Python.
*
@@ -726,6 +829,8 @@ private void handleClickMenu() {
if (menuItem.getItemId() == R.id.device_location_history) {
this.redirectToDeviceHistory();
+ } else if (menuItem.getItemId() == R.id.device_play_sound_nearby) {
+ this.onClickPlaySoundNearby();
} else if (menuItem.getItemId() == R.id.device_delete) {
this.onClickDeviceDelete();
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java
new file mode 100644
index 00000000..3ca7e238
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java
@@ -0,0 +1,26 @@
+package dev.wander.android.opentagviewer.ble;
+
+import android.content.Context;
+
+import io.reactivex.rxjava3.core.Single;
+
+/**
+ * Plays an owned accessory's sound directly over Bluetooth, without going through Apple's Find
+ * My network - the same thing Find My itself does when a tag is close enough to reach.
+ *
+ *
Behind an interface for the reason every Chaquopy/hardware dependency in this app is: the
+ * real implementation needs Bluetooth radio and a nearby accessory, neither of which a test can
+ * arrange - see {@code AppDependencies}.
+ */
+public interface AccessorySoundTrigger {
+
+ /**
+ * @param context used for the Bluetooth system service and permission checks.
+ * @param accessoryJson the persisted {@code OwnedBeacon.accessoryJson} for this beacon.
+ * @return a {@link Single} emitting exactly one {@link BleSoundTriggerResult}. Never errors -
+ * every failure this can hit (no permission, not in range, connect/write failure) is a
+ * status on the result, not an exception, so a caller only ever needs {@code subscribe} with
+ * one lambda.
+ */
+ Single playSound(Context context, String accessoryJson);
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessoryMatcher.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessoryMatcher.java
new file mode 100644
index 00000000..50edccb6
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessoryMatcher.java
@@ -0,0 +1,35 @@
+package dev.wander.android.opentagviewer.ble;
+
+import java.util.Locale;
+import java.util.Set;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+/**
+ * Whether a scanned BLE address is one of an accessory's currently-expected MAC addresses.
+ *
+ * Pulled out as a pure function, deliberately not Android or Chaquopy: both sides of this
+ * comparison are stated to be uppercase already - {@code BluetoothDevice.getAddress()} by
+ * Android's own contract, {@code KeyPair.mac_address} by FindMy.py's implementation - but a
+ * platform or library changing that quietly would fail silently as "tag never found" rather than
+ * loudly, so this normalises rather than trusting it. Kept free of both dependencies so this,
+ * the part that actually decides a match, is the part with a test that runs on plain JVM.
+ */
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class BleAccessoryMatcher {
+
+ public static boolean matches(final String scannedDeviceAddress, final Set candidateMacs) {
+ if (scannedDeviceAddress == null || candidateMacs.isEmpty()) {
+ return false;
+ }
+
+ final String normalised = scannedDeviceAddress.toUpperCase(Locale.ROOT);
+ for (final String candidate : candidateMacs) {
+ if (candidate != null && candidate.toUpperCase(Locale.ROOT).equals(normalised)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
new file mode 100644
index 00000000..63c4d4d5
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
@@ -0,0 +1,68 @@
+package dev.wander.android.opentagviewer.ble;
+
+import android.content.Context;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import dev.wander.android.opentagviewer.python.AccessoryMacResolver;
+import io.reactivex.rxjava3.core.Single;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
+/**
+ * The real {@link AccessorySoundTrigger}: resolves candidate MACs through Python, scans for one
+ * of them, and triggers the accessory's GATT sound service once found.
+ *
+ * What this has actually been run against, and what it has not. The GATT protocol
+ * logic in {@link BleGattSoundTrigger} is a port of a Kotlin prototype (a personal companion
+ * project, TrackerHunter) that was exercised against real AirTags over BLE. This class - the
+ * permission gate, the MAC resolution via the pinned FindMy.py fork, and wiring the scan result
+ * into the trigger - has been read carefully but not run end-to-end on a device by whoever
+ * wrote it. Per AGENTS.md rule 2: say so rather than claim otherwise.
+ */
+public class BleAccessorySoundTrigger implements AccessorySoundTrigger {
+
+ /**
+ * How long to scan before giving up. Long enough that an AirTag's ~1 second-ish advertising
+ * interval is seen several times over, short enough that tapping the button and walking away
+ * does not leave a scan running indefinitely.
+ */
+ private static final long SCAN_TIMEOUT_MS = 15_000L;
+
+ private final AccessoryMacResolver macResolver;
+
+ public BleAccessorySoundTrigger(final AccessoryMacResolver macResolver) {
+ this.macResolver = macResolver;
+ }
+
+ @Override
+ public Single playSound(final Context context, final String accessoryJson) {
+ return Single.defer(() -> {
+ if (!BlePermissions.granted(context)) {
+ return Single.just(new BleSoundTriggerResult(BleSoundTriggerStatus.MISSING_PERMISSION,
+ null, "Bluetooth scan/connect permission not granted"));
+ }
+
+ // Blocking - starts a Python interpreter. Safe here because the whole chain is
+ // subscribed on Schedulers.io() below, same as PythonAppleService's calls.
+ final List macs = macResolver.currentMacAddresses(accessoryJson);
+ if (macs.isEmpty()) {
+ return Single.just(new BleSoundTriggerResult(BleSoundTriggerStatus.NO_CANDIDATE_MACS,
+ null, "Could not resolve a current MAC address for this accessory"));
+ }
+ final Set candidates = new HashSet<>(macs);
+
+ return NearbyAccessoryScanner.findNearby(context, candidates, SCAN_TIMEOUT_MS)
+ .flatMap(device -> BleGattSoundTrigger.trigger(context, device))
+ .onErrorReturn(BleAccessorySoundTrigger::asResult);
+ }).subscribeOn(Schedulers.io());
+ }
+
+ private static BleSoundTriggerResult asResult(final Throwable error) {
+ if (error instanceof NearbyAccessoryScanner.NotNearbyException) {
+ return new BleSoundTriggerResult(BleSoundTriggerStatus.NOT_NEARBY, null, error.getMessage());
+ }
+ return new BleSoundTriggerResult(BleSoundTriggerStatus.FAILED, null, String.valueOf(error.getMessage()));
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java
new file mode 100644
index 00000000..d1586692
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java
@@ -0,0 +1,230 @@
+package dev.wander.android.opentagviewer.ble;
+
+import android.annotation.SuppressLint;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.os.Build;
+import android.util.Log;
+
+import java.util.Locale;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import io.reactivex.rxjava3.core.Single;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+/**
+ * Play-sound GATT trigger for Find My / DULT-compatible accessories.
+ *
+ * Tries three known protocols, in the same priority order as AirGuard
+ * (https://github.com/seemoo-lab/AirGuard, Apache-2.0) uses - the UUIDs and opcodes are verified
+ * against that project's current source ({@code database/models/device/types/AppleFindMy.kt} for
+ * the DULT and AirTag-specific paths, {@code GoogleFindMyNetwork.kt} for confirming Google's own
+ * sound service is byte-for-byte the same one DULT defines).
+ *
+ *
Ported from a Kotlin prototype (a personal companion project, TrackerHunter) that already
+ * exercised this against real AirTags; this is the same state machine expressed as a Java
+ * {@link Single} instead of a coroutine, to match this app's RxJava3 convention. See
+ * {@code BleAccessorySoundTrigger} for the honesty about what has and has not actually been run.
+ */
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class BleGattSoundTrigger {
+ private static final String TAG = BleGattSoundTrigger.class.getSimpleName();
+
+ private static final UUID DULT_SERVICE =
+ UUID.fromString("15190001-12F4-C226-88ED-2AC5579F2A85");
+ private static final UUID DULT_CHARACTERISTIC =
+ UUID.fromString("8E0C0001-1D68-FB92-BF61-48377421680E");
+ private static final byte[] DULT_START_OPCODE = {0x00, 0x03};
+
+ private static final String FINDMY_SERVICE_SHORT = "fd44";
+ private static final UUID FINDMY_CHARACTERISTIC =
+ UUID.fromString("4F860003-943B-49EF-BED4-2F730304427A");
+ private static final byte[] FINDMY_START_OPCODE = {0x01, 0x00, 0x03};
+
+ private static final UUID AIRTAG_SERVICE =
+ UUID.fromString("7DFC9000-7D1C-4951-86AA-8D9728F8D66C");
+ private static final UUID AIRTAG_CHARACTERISTIC =
+ UUID.fromString("7DFC9001-7D1C-4951-86AA-8D9728F8D66C");
+ private static final byte[] AIRTAG_PLAY_VALUE = {(byte) 0xAF};
+
+ private static final UUID CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
+
+ /**
+ * Connects to {@code device}, tries all three protocols, and completes once the first
+ * matching one's start command has been written (or all three failed). Does not wait for the
+ * sound to finish playing.
+ *
+ *
Emits exactly once. Disposing the returned {@link Single} before it emits disconnects
+ * and closes the GATT connection rather than leaving it open in the background.
+ */
+ @SuppressLint("MissingPermission")
+ public static Single trigger(
+ final Context context, final BluetoothDevice device) {
+ return Single.create(emitter -> {
+ final AtomicBoolean resumed = new AtomicBoolean(false);
+ final BluetoothGatt[] gattRef = new BluetoothGatt[1];
+
+ final BluetoothGattCallback callback = new BluetoothGattCallback() {
+ private BluetoothGattCharacteristic pendingCharacteristic;
+ private byte[] pendingOpcode;
+ private String pendingProtocolName;
+
+ private void finish(final BleSoundTriggerResult result) {
+ // Guards against a callback landing twice (e.g. a disconnect that follows a
+ // successful write) - only the first one reaches the emitter, matching
+ // Single's exactly-once contract.
+ if (!resumed.compareAndSet(false, true)) return;
+ if (!emitter.isDisposed()) emitter.onSuccess(result);
+ }
+
+ @Override
+ public void onConnectionStateChange(
+ final BluetoothGatt gatt, final int status, final int newState) {
+ if (newState == BluetoothProfile.STATE_CONNECTED) {
+ Log.d(TAG, "Connected to " + device.getAddress()
+ + ", discovering services");
+ gatt.discoverServices();
+ } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
+ Log.d(TAG, "Disconnected from " + device.getAddress());
+ gatt.close();
+ // A disconnect after a successful write is the normal AirTag completion
+ // signal, not a failure - finish() already resumed by then, so this call
+ // is a no-op (see the AtomicBoolean guard above).
+ finish(new BleSoundTriggerResult(BleSoundTriggerStatus.FAILED, null,
+ "Connection closed before a sound command was sent"));
+ }
+ }
+
+ @Override
+ public void onServicesDiscovered(final BluetoothGatt gatt, final int status) {
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ finish(new BleSoundTriggerResult(BleSoundTriggerStatus.FAILED, null,
+ "Service discovery failed (status=" + status + ")"));
+ gatt.disconnect();
+ return;
+ }
+
+ final BluetoothGattCharacteristic dult = characteristicOf(gatt, DULT_SERVICE, DULT_CHARACTERISTIC);
+ final BluetoothGattCharacteristic findMy = findMyCharacteristic(gatt);
+ final BluetoothGattCharacteristic airtag = characteristicOf(gatt, AIRTAG_SERVICE, AIRTAG_CHARACTERISTIC);
+
+ if (dult != null) {
+ enableNotifyThenWrite(gatt, dult, DULT_START_OPCODE, "DULT");
+ } else if (findMy != null) {
+ enableNotifyThenWrite(gatt, findMy, FINDMY_START_OPCODE, "FindMy (fd44)");
+ } else if (airtag != null) {
+ pendingProtocolName = "AirTag";
+ writeCharacteristicCompat(gatt, airtag, AIRTAG_PLAY_VALUE);
+ } else {
+ finish(new BleSoundTriggerResult(BleSoundTriggerStatus.NO_SOUND_SERVICE, null,
+ "No known sound service found (checked DULT, FindMy, AirTag)"));
+ gatt.disconnect();
+ }
+ }
+
+ private void enableNotifyThenWrite(
+ final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic,
+ final byte[] opcode, final String protocolName) {
+ pendingProtocolName = protocolName;
+ pendingCharacteristic = characteristic;
+ pendingOpcode = opcode;
+
+ gatt.setCharacteristicNotification(characteristic, true);
+ final BluetoothGattDescriptor descriptor = characteristic.getDescriptor(CCCD);
+ if (descriptor == null) {
+ // No CCCD - just write directly, matching the AirTag path.
+ writeCharacteristicCompat(gatt, characteristic, opcode);
+ return;
+ }
+ writeDescriptorCompat(gatt, descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
+ }
+
+ @Override
+ public void onDescriptorWrite(
+ final BluetoothGatt gatt, final BluetoothGattDescriptor descriptor, final int status) {
+ if (pendingCharacteristic == null || pendingOpcode == null) return;
+ Log.d(TAG, "CCCD write status=" + status + ", writing start opcode to "
+ + pendingCharacteristic.getUuid());
+ writeCharacteristicCompat(gatt, pendingCharacteristic, pendingOpcode);
+ }
+
+ @Override
+ public void onCharacteristicWrite(
+ final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic,
+ final int status) {
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ final String protocol = pendingProtocolName == null ? "unknown" : pendingProtocolName;
+ Log.i(TAG, "Sound triggered via " + protocol + " on " + device.getAddress());
+ finish(new BleSoundTriggerResult(BleSoundTriggerStatus.SUCCESS, protocol, null));
+ // AirTag disconnects on its own once the sound finishes; DULT/FindMy
+ // don't, so force it here - gives every trigger() call a bounded
+ // lifetime.
+ if (!"AirTag".equals(protocol)) {
+ gatt.disconnect();
+ }
+ } else {
+ finish(new BleSoundTriggerResult(BleSoundTriggerStatus.FAILED, null,
+ "Write failed (status=" + status + ")"));
+ gatt.disconnect();
+ }
+ }
+ };
+
+ gattRef[0] = device.connectGatt(context, false, callback);
+
+ emitter.setCancellable(() -> {
+ if (gattRef[0] != null) {
+ gattRef[0].disconnect();
+ gattRef[0].close();
+ }
+ });
+ });
+ }
+
+ private static BluetoothGattCharacteristic characteristicOf(
+ final BluetoothGatt gatt, final UUID service, final UUID characteristic) {
+ final BluetoothGattService svc = gatt.getService(service);
+ return svc == null ? null : svc.getCharacteristic(characteristic);
+ }
+
+ /** The FindMy/DULT service UUID is vendor-suffixed; matched on its distinguishing prefix. */
+ private static BluetoothGattCharacteristic findMyCharacteristic(final BluetoothGatt gatt) {
+ for (final BluetoothGattService service : gatt.getServices()) {
+ if (service.getUuid().toString().toLowerCase(Locale.ROOT).contains(FINDMY_SERVICE_SHORT)) {
+ return service.getCharacteristic(FINDMY_CHARACTERISTIC);
+ }
+ }
+ return null;
+ }
+
+ @SuppressLint("MissingPermission")
+ private static void writeCharacteristicCompat(
+ final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic, final byte[] value) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ gatt.writeCharacteristic(characteristic, value, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT);
+ } else {
+ characteristic.setValue(value);
+ characteristic.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT);
+ gatt.writeCharacteristic(characteristic);
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ private static void writeDescriptorCompat(
+ final BluetoothGatt gatt, final BluetoothGattDescriptor descriptor, final byte[] value) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ gatt.writeDescriptor(descriptor, value);
+ } else {
+ descriptor.setValue(value);
+ gatt.writeDescriptor(descriptor);
+ }
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BlePermissions.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BlePermissions.java
new file mode 100644
index 00000000..34ad00f1
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BlePermissions.java
@@ -0,0 +1,50 @@
+package dev.wander.android.opentagviewer.ble;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Build;
+
+import androidx.core.content.ContextCompat;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+/**
+ * What BLE scanning and GATT connection need at runtime, in one place.
+ *
+ * Shared between the activity that requests these permissions and
+ * {@link BleAccessorySoundTrigger}, which depends on them being granted, so the two cannot
+ * silently disagree about what "enough" means - the same reasoning as AGENTS.md's rule on
+ * putting a provider decision behind one abstraction rather than branching in more than one
+ * place.
+ */
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class BlePermissions {
+
+ /**
+ * Android 12+ (API 31) needs {@code BLUETOOTH_SCAN}/{@code BLUETOOTH_CONNECT}; below that,
+ * BLE scanning is gated on location instead. Both are already declared unconditionally in
+ * the manifest, for the map feature - this only asks whether they are granted *yet*.
+ */
+ public static String[] required() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ return new String[]{Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT};
+ }
+ return new String[]{Manifest.permission.ACCESS_FINE_LOCATION};
+ }
+
+ public static boolean granted(final Context context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ return isGranted(context, Manifest.permission.BLUETOOTH_SCAN)
+ && isGranted(context, Manifest.permission.BLUETOOTH_CONNECT);
+ }
+ return isGranted(context, Manifest.permission.ACCESS_FINE_LOCATION)
+ || isGranted(context, Manifest.permission.ACCESS_COARSE_LOCATION);
+ }
+
+ private static boolean isGranted(final Context context, final String permission) {
+ return ContextCompat.checkSelfPermission(context, permission)
+ == PackageManager.PERMISSION_GRANTED;
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java
new file mode 100644
index 00000000..c4a33d3e
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java
@@ -0,0 +1,17 @@
+package dev.wander.android.opentagviewer.ble;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/** Outcome of one {@link BleAccessorySoundTrigger#playSound} attempt. */
+@AllArgsConstructor
+@Getter
+public class BleSoundTriggerResult {
+ private final BleSoundTriggerStatus status;
+
+ /** Which GATT protocol answered - "DULT", "FindMy (fd44)" or "AirTag" - null unless SUCCESS. */
+ private final String protocol;
+
+ /** Detail for logs, in whatever language the underlying failure happened to arrive in. */
+ private final String message;
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerStatus.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerStatus.java
new file mode 100644
index 00000000..96bcb944
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerStatus.java
@@ -0,0 +1,22 @@
+package dev.wander.android.opentagviewer.ble;
+
+/** How a {@link BleAccessorySoundTrigger#playSound} attempt ended. */
+public enum BleSoundTriggerStatus {
+ /** The start command was written; the accessory is (or was) playing its sound. */
+ SUCCESS,
+
+ /** The accessory's resolved candidate MAC address set was empty; nothing to scan for. */
+ NO_CANDIDATE_MACS,
+
+ /** The scan window ended without seeing any of the candidate MACs advertise. */
+ NOT_NEARBY,
+
+ /** Connected, but none of the known GATT sound services (DULT, FindMy, AirTag) were found. */
+ NO_SOUND_SERVICE,
+
+ /** A required runtime permission (scan or connect) is not granted. */
+ MISSING_PERMISSION,
+
+ /** Bluetooth is off, or connecting/writing otherwise failed. */
+ FAILED,
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyAccessoryScanner.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyAccessoryScanner.java
new file mode 100644
index 00000000..eaab8958
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyAccessoryScanner.java
@@ -0,0 +1,94 @@
+package dev.wander.android.opentagviewer.ble;
+
+import android.annotation.SuppressLint;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanResult;
+import android.bluetooth.le.ScanSettings;
+import android.content.Context;
+
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.core.Single;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+/**
+ * Scans for a BLE advertisement whose address matches one of an accessory's currently-expected
+ * MAC addresses - see {@link BleAccessoryMatcher} - and resolves with the first one seen.
+ *
+ *
Unfiltered scan rather than a {@code ScanFilter} on the address, deliberately: the address
+ * that matters is the one Android reports on the {@link ScanResult}, and a filter is matched
+ * against the *raw advertisement bytes* the platform saw before it decided what address to
+ * report - the two need not agree on every OEM's stack. Matching after the fact in
+ * {@link BleAccessoryMatcher} is the same trade AirGuard and the TrackerHunter prototype this
+ * was ported from both made, for the same reason.
+ */
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class NearbyAccessoryScanner {
+
+ /** A scan finished without seeing any of the candidate addresses. */
+ public static final class NotNearbyException extends Exception {
+ NotNearbyException() {
+ super("No candidate MAC address was seen advertising within the scan window");
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ public static Single findNearby(
+ final Context context, final Set candidateMacs, final long timeoutMs) {
+ return Observable.create(emitter -> {
+ final BluetoothManager manager =
+ (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
+ final BluetoothAdapter adapter = manager == null ? null : manager.getAdapter();
+ final BluetoothLeScanner scanner =
+ adapter == null ? null : adapter.getBluetoothLeScanner();
+
+ if (scanner == null) {
+ emitter.onError(new IllegalStateException(
+ "No BLE scanner available (Bluetooth off, or unsupported)"));
+ return;
+ }
+
+ final ScanCallback callback = new ScanCallback() {
+ @Override
+ public void onScanResult(final int callbackType, final ScanResult result) {
+ final BluetoothDevice device = result.getDevice();
+ if (BleAccessoryMatcher.matches(device.getAddress(), candidateMacs)
+ && !emitter.isDisposed()) {
+ emitter.onNext(device);
+ emitter.onComplete();
+ }
+ }
+
+ @Override
+ public void onScanFailed(final int errorCode) {
+ if (!emitter.isDisposed()) {
+ emitter.onError(new IllegalStateException(
+ "BLE scan failed (errorCode=" + errorCode + ")"));
+ }
+ }
+ };
+
+ // LOW_LATENCY over the default balanced mode: this only ever runs for the few
+ // seconds after the user explicitly asked to trigger a sound, not continuously in
+ // the background, so there is no battery budget to protect here.
+ final ScanSettings settings = new ScanSettings.Builder()
+ .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
+ .build();
+ scanner.startScan(null, settings, callback);
+
+ emitter.setCancellable(() -> scanner.stopScan(callback));
+ })
+ .firstOrError()
+ .timeout(timeoutMs, TimeUnit.MILLISECONDS)
+ .onErrorResumeNext(error -> error instanceof java.util.concurrent.TimeoutException
+ ? Single.error(new NotNearbyException())
+ : Single.error(error));
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java
new file mode 100644
index 00000000..44a406e2
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java
@@ -0,0 +1,28 @@
+package dev.wander.android.opentagviewer.python;
+
+import java.util.List;
+
+/**
+ * The BLE MAC address(es) an accessory might currently be advertising.
+ *
+ * Behind an interface for the same reason as {@link HardwareDescriber}: the real one is
+ * Chaquopy, starts an interpreter and runs an EC point derivation, so a screen that called it
+ * directly could not be launched in a test without all of that working.
+ *
+ *
Used to recognise an owned accessory's own advertisement in a BLE scan - see the {@code ble}
+ * package - so it can be triggered directly (playing a sound) without going through Apple's Find
+ * My network, the same thing Find My itself does when a tag is close enough to reach over
+ * Bluetooth.
+ */
+public interface AccessoryMacResolver {
+
+ /**
+ * @param accessoryJson the persisted {@code OwnedBeacon.accessoryJson} for this beacon.
+ * @return the candidate MAC address(es), or an empty list if none could be resolved. An
+ * unreadable or null {@code accessoryJson} reports empty rather than throwing, since a
+ * beacon whose accessory JSON has not yet been backfilled (see
+ * {@code OwnedBeacon.accessoryJson}) is a real state the caller must be able to show, not a
+ * bug in this call.
+ */
+ List currentMacAddresses(String accessoryJson);
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java
index 6cfc11fb..3a181330 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java
@@ -1,232 +1,273 @@
-package dev.wander.android.opentagviewer.python;
-
-import android.content.Context;
-import android.location.Geocoder;
-
-import org.chromium.net.CronetEngine;
-
-import androidx.annotation.VisibleForTesting;
-
-import java.util.Locale;
-import java.util.function.BiFunction;
-import java.util.function.Function;
-import java.util.function.Supplier;
-
-import dev.wander.android.opentagviewer.anisette.AnisetteSource;
-import dev.wander.android.opentagviewer.anisette.LocalAnisette;
-import dev.wander.android.opentagviewer.python.icloud.ICloudService;
-import dev.wander.android.opentagviewer.python.icloud.PythonICloudService;
-import dev.wander.android.opentagviewer.db.repo.model.UserSettings;
-import dev.wander.android.opentagviewer.service.web.AnisetteServerTesterService;
-import dev.wander.android.opentagviewer.util.android.AddressLookup;
-
-/**
- * What the sign-in screen depends on, in one place a test can replace.
- *
- * The screen builds everything it needs inside {@code onCreate}, which is the ordinary
- * Android shape and fine right up until you want to launch it. Two of those things reach the
- * network before a single view is drawn: signing in runs Python against Apple, and local
- * Anisette downloads Apple's ADI libraries from their CDN. Neither can be arranged in a test,
- * so the whole four-page flow - the part of the app with the most transitions and the least
- * coverage - could only ever be checked by hand with a real account and a real phone.
- *
- *
A settable global rather than constructor injection because an activity is
- * constructed by the framework, and this app has no DI container to teach otherwise. The
- * alternative shapes all cost more than they are worth here: an Application subclass holding
- * these is the same global with more indirection, and a whole framework is a large change to
- * this codebase for one screen. Production never calls the setters; they are for tests, and
- * {@link #reset()} in a teardown puts the real ones back.
- */
-public final class AppDependencies {
-
- private AppDependencies() {}
-
- private static AppleAuthService authService = new PythonAppleAuthService();
-
- /**
- * How to build Anisette for a given settings object. A factory rather than an instance
- * because the real one needs a Context and the current settings, and neither exists when
- * this class is loaded.
- */
- private static AnisetteFactory anisetteFactory = LocalAnisette::new;
-
- /** Builds the Anisette source for a screen, given where it is running and who is signed in. */
- public interface AnisetteFactory {
- AnisetteSource create(Context context, UserSettings settings, boolean hasExistingSession);
- }
-
- /**
- * How to build the thing that asks an Anisette server whether it is alive.
- *
- *
Here for the same reason as the rest: the sign-in screen tests a server before it
- * will let anybody past, so a test of the fall-back path would otherwise depend on a
- * stranger's machine being up.
- */
- private static Function serverTesterFactory =
- AnisetteServerTesterService::new;
-
- /**
- * Names an accessory from its plist, through the shared Python heuristic.
- *
- * Here for the same reason as the rest: the real one starts Chaquopy and imports a
- * package, so a screen that used it directly could not be launched in a test. It also makes
- * "an accessory nothing recognises" renderable on demand, rather than needing such a tag.
- */
- private static HardwareDescriber hardwareDescriber = new ChaquopyHardwareDescriber();
-
- /**
- * Strips personal identifiers out of a log before it is offered to anybody.
- *
- *
Here for the usual reason and one sharper one: the screen that offers a log is the error
- * page, which exists because something already broke. A test of it has to be able to
- * produce a working redactor and one that cannot run, and the second is the case that decides
- * whether an unredacted log can escape.
- */
- private static LogRedactor logRedactor = new ChaquopyLogRedactor();
-
- /**
- * Builds an export bundle's files.
- *
- *
Here because the failure path is the one that matters and cannot be reached on
- * demand. An export that throws leaves somebody with no file and no explanation, having
- * just decided to share the keys to their tags - and producing that state for real means
- * breaking the interpreter. A fake produces it in a line.
- */
- private static BundleBuilder bundleBuilder = new ChaquopyBundleBuilder();
-
- /**
- * Turns coordinates into something a person recognises.
- *
- *
Here because a screen with no geocoder does not look broken. The card falls back
- * to the raw latitude and longitude, which is a perfectly reasonable thing for it to show
- * when an address genuinely cannot be found - so a geocoder that answers nothing at all is
- * indistinguishable, on screen and in a screenshot, from one that answered honestly.
- *
- *
Which is the state every instrumented run is in: the {@code aosp-atd} image carries no
- * geocoding backend, so {@code getFromLocation} returns an empty list for every point on
- * earth and the whole path - the rounding, the cache, the fallback - is exercised by
- * nothing. A test that wants to assert a place name has to be able to supply one.
- *
- *
A factory rather than an instance, because a {@link Geocoder} is built per screen from
- * that screen's context and the current locale.
- */
- private static BiFunction geocoderFactory =
- (context, locale) -> AddressLookup.through(new Geocoder(context, locale));
-
- public static AddressLookup geocoder(final Context context, final Locale locale) {
- return geocoderFactory.apply(context, locale);
- }
-
- @VisibleForTesting
- public static void replaceGeocoder(
- final BiFunction replacement) {
- geocoderFactory = replacement;
- }
-
- /**
- * Opens a conversation with iCloud on the signed-in account.
- *
- * A supplier rather than an instance because a session is not reusable: it holds a
- * keychain session and a CloudKit client, both with sockets, and it is closed when the
- * screen that opened it goes away.
- *
- *
Here for the usual reason, more sharply than most. Every failure this flow has to
- * handle - an account with nothing to recover from, a service having a bad day, a rejected
- * passcode - needs an Apple account in a state nobody can arrange on demand, and the ones
- * that matter most are the ones a real account will never be in.
- */
- private static Supplier icloudFactory = AppDependencies::openRealICloud;
-
- private static ICloudService openRealICloud() {
- final PythonAppleService signedIn = PythonAppleService.getInstance();
- if (signedIn == null || signedIn.getAccount() == null) {
- return null;
- }
-
- return PythonICloudService.openFor(signedIn.getAccount());
- }
-
- /**
- * A new iCloud session, or null when there is no usable signed-in account.
- *
- * Null is not a crash: the caller reports it as needing a sign-in, which is the same
- * recovery as a session that has expired.
- */
- public static ICloudService icloud() {
- return icloudFactory.get();
- }
-
- @VisibleForTesting
- public static void replaceICloud(final Supplier replacement) {
- icloudFactory = replacement;
- }
-
- public static AppleAuthService authService() {
- return authService;
- }
-
- public static HardwareDescriber hardwareDescriber() {
- return hardwareDescriber;
- }
-
- public static LogRedactor logRedactor() {
- return logRedactor;
- }
-
- public static BundleBuilder bundleBuilder() {
- return bundleBuilder;
- }
-
- public static AnisetteServerTesterService serverTester(final CronetEngine engine) {
- return serverTesterFactory.apply(engine);
- }
-
- @VisibleForTesting
- public static void replaceServerTester(final AnisetteServerTesterService replacement) {
- serverTesterFactory = engine -> replacement;
- }
-
- public static AnisetteSource anisette(
- final Context context, final UserSettings settings, final boolean hasExistingSession) {
- return anisetteFactory.create(context, settings, hasExistingSession);
- }
-
- @VisibleForTesting
- public static void replaceAuthService(final AppleAuthService replacement) {
- authService = replacement;
- }
-
- @VisibleForTesting
- public static void replaceHardwareDescriber(final HardwareDescriber replacement) {
- hardwareDescriber = replacement;
- }
-
- @VisibleForTesting
- public static void replaceLogRedactor(final LogRedactor replacement) {
- logRedactor = replacement;
- }
-
- @VisibleForTesting
- public static void replaceBundleBuilder(final BundleBuilder replacement) {
- bundleBuilder = replacement;
- }
-
- @VisibleForTesting
- public static void replaceAnisette(final Function replacement) {
- anisetteFactory = (context, settings, hasSession) -> replacement.apply(settings);
- }
-
- /** Put the real ones back. Call from a teardown, or the next test inherits a fake. */
- @VisibleForTesting
- public static void reset() {
- authService = new PythonAppleAuthService();
- anisetteFactory = LocalAnisette::new;
- serverTesterFactory = AnisetteServerTesterService::new;
- hardwareDescriber = new ChaquopyHardwareDescriber();
- logRedactor = new ChaquopyLogRedactor();
- bundleBuilder = new ChaquopyBundleBuilder();
- icloudFactory = AppDependencies::openRealICloud;
- geocoderFactory = (context, locale) ->
- AddressLookup.through(new Geocoder(context, locale));
- }
-}
+package dev.wander.android.opentagviewer.python;
+
+import android.content.Context;
+import android.location.Geocoder;
+
+import org.chromium.net.CronetEngine;
+
+import androidx.annotation.VisibleForTesting;
+
+import java.util.Locale;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import dev.wander.android.opentagviewer.anisette.AnisetteSource;
+import dev.wander.android.opentagviewer.anisette.LocalAnisette;
+import dev.wander.android.opentagviewer.ble.AccessorySoundTrigger;
+import dev.wander.android.opentagviewer.ble.BleAccessorySoundTrigger;
+import dev.wander.android.opentagviewer.python.icloud.ICloudService;
+import dev.wander.android.opentagviewer.python.icloud.PythonICloudService;
+import dev.wander.android.opentagviewer.db.repo.model.UserSettings;
+import dev.wander.android.opentagviewer.service.web.AnisetteServerTesterService;
+import dev.wander.android.opentagviewer.util.android.AddressLookup;
+
+/**
+ * What the sign-in screen depends on, in one place a test can replace.
+ *
+ * The screen builds everything it needs inside {@code onCreate}, which is the ordinary
+ * Android shape and fine right up until you want to launch it. Two of those things reach the
+ * network before a single view is drawn: signing in runs Python against Apple, and local
+ * Anisette downloads Apple's ADI libraries from their CDN. Neither can be arranged in a test,
+ * so the whole four-page flow - the part of the app with the most transitions and the least
+ * coverage - could only ever be checked by hand with a real account and a real phone.
+ *
+ *
A settable global rather than constructor injection because an activity is
+ * constructed by the framework, and this app has no DI container to teach otherwise. The
+ * alternative shapes all cost more than they are worth here: an Application subclass holding
+ * these is the same global with more indirection, and a whole framework is a large change to
+ * this codebase for one screen. Production never calls the setters; they are for tests, and
+ * {@link #reset()} in a teardown puts the real ones back.
+ */
+public final class AppDependencies {
+
+ private AppDependencies() {}
+
+ private static AppleAuthService authService = new PythonAppleAuthService();
+
+ /**
+ * How to build Anisette for a given settings object. A factory rather than an instance
+ * because the real one needs a Context and the current settings, and neither exists when
+ * this class is loaded.
+ */
+ private static AnisetteFactory anisetteFactory = LocalAnisette::new;
+
+ /** Builds the Anisette source for a screen, given where it is running and who is signed in. */
+ public interface AnisetteFactory {
+ AnisetteSource create(Context context, UserSettings settings, boolean hasExistingSession);
+ }
+
+ /**
+ * How to build the thing that asks an Anisette server whether it is alive.
+ *
+ *
Here for the same reason as the rest: the sign-in screen tests a server before it
+ * will let anybody past, so a test of the fall-back path would otherwise depend on a
+ * stranger's machine being up.
+ */
+ private static Function serverTesterFactory =
+ AnisetteServerTesterService::new;
+
+ /**
+ * Names an accessory from its plist, through the shared Python heuristic.
+ *
+ * Here for the same reason as the rest: the real one starts Chaquopy and imports a
+ * package, so a screen that used it directly could not be launched in a test. It also makes
+ * "an accessory nothing recognises" renderable on demand, rather than needing such a tag.
+ */
+ private static HardwareDescriber hardwareDescriber = new ChaquopyHardwareDescriber();
+
+ /**
+ * Resolves an accessory's current BLE MAC address candidate(s), through the pinned
+ * FindMy.py fork's rolling-key derivation.
+ *
+ *
Here for the same reason as {@link #hardwareDescriber}: the real one starts Chaquopy,
+ * so a screen or a test of {@link #accessorySoundTrigger} could not otherwise run without it.
+ */
+ private static AccessoryMacResolver accessoryMacResolver = new ChaquopyAccessoryMacResolver();
+
+ /**
+ * Plays an owned accessory's sound directly over Bluetooth - see
+ * {@code dev.wander.android.opentagviewer.ble.AccessorySoundTrigger}.
+ *
+ *
Built from {@link #accessoryMacResolver} rather than constructing its own, so replacing
+ * one in a test replaces what the other depends on too.
+ */
+ private static AccessorySoundTrigger accessorySoundTrigger =
+ new BleAccessorySoundTrigger(accessoryMacResolver);
+
+ /**
+ * Strips personal identifiers out of a log before it is offered to anybody.
+ *
+ *
Here for the usual reason and one sharper one: the screen that offers a log is the error
+ * page, which exists because something already broke. A test of it has to be able to
+ * produce a working redactor and one that cannot run, and the second is the case that decides
+ * whether an unredacted log can escape.
+ */
+ private static LogRedactor logRedactor = new ChaquopyLogRedactor();
+
+ /**
+ * Builds an export bundle's files.
+ *
+ *
Here because the failure path is the one that matters and cannot be reached on
+ * demand. An export that throws leaves somebody with no file and no explanation, having
+ * just decided to share the keys to their tags - and producing that state for real means
+ * breaking the interpreter. A fake produces it in a line.
+ */
+ private static BundleBuilder bundleBuilder = new ChaquopyBundleBuilder();
+
+ /**
+ * Turns coordinates into something a person recognises.
+ *
+ *
Here because a screen with no geocoder does not look broken. The card falls back
+ * to the raw latitude and longitude, which is a perfectly reasonable thing for it to show
+ * when an address genuinely cannot be found - so a geocoder that answers nothing at all is
+ * indistinguishable, on screen and in a screenshot, from one that answered honestly.
+ *
+ *
Which is the state every instrumented run is in: the {@code aosp-atd} image carries no
+ * geocoding backend, so {@code getFromLocation} returns an empty list for every point on
+ * earth and the whole path - the rounding, the cache, the fallback - is exercised by
+ * nothing. A test that wants to assert a place name has to be able to supply one.
+ *
+ *
A factory rather than an instance, because a {@link Geocoder} is built per screen from
+ * that screen's context and the current locale.
+ */
+ private static BiFunction geocoderFactory =
+ (context, locale) -> AddressLookup.through(new Geocoder(context, locale));
+
+ public static AddressLookup geocoder(final Context context, final Locale locale) {
+ return geocoderFactory.apply(context, locale);
+ }
+
+ @VisibleForTesting
+ public static void replaceGeocoder(
+ final BiFunction replacement) {
+ geocoderFactory = replacement;
+ }
+
+ /**
+ * Opens a conversation with iCloud on the signed-in account.
+ *
+ * A supplier rather than an instance because a session is not reusable: it holds a
+ * keychain session and a CloudKit client, both with sockets, and it is closed when the
+ * screen that opened it goes away.
+ *
+ *
Here for the usual reason, more sharply than most. Every failure this flow has to
+ * handle - an account with nothing to recover from, a service having a bad day, a rejected
+ * passcode - needs an Apple account in a state nobody can arrange on demand, and the ones
+ * that matter most are the ones a real account will never be in.
+ */
+ private static Supplier icloudFactory = AppDependencies::openRealICloud;
+
+ private static ICloudService openRealICloud() {
+ final PythonAppleService signedIn = PythonAppleService.getInstance();
+ if (signedIn == null || signedIn.getAccount() == null) {
+ return null;
+ }
+
+ return PythonICloudService.openFor(signedIn.getAccount());
+ }
+
+ /**
+ * A new iCloud session, or null when there is no usable signed-in account.
+ *
+ * Null is not a crash: the caller reports it as needing a sign-in, which is the same
+ * recovery as a session that has expired.
+ */
+ public static ICloudService icloud() {
+ return icloudFactory.get();
+ }
+
+ @VisibleForTesting
+ public static void replaceICloud(final Supplier replacement) {
+ icloudFactory = replacement;
+ }
+
+ public static AppleAuthService authService() {
+ return authService;
+ }
+
+ public static HardwareDescriber hardwareDescriber() {
+ return hardwareDescriber;
+ }
+
+ public static AccessoryMacResolver accessoryMacResolver() {
+ return accessoryMacResolver;
+ }
+
+ public static AccessorySoundTrigger accessorySoundTrigger() {
+ return accessorySoundTrigger;
+ }
+
+ public static LogRedactor logRedactor() {
+ return logRedactor;
+ }
+
+ public static BundleBuilder bundleBuilder() {
+ return bundleBuilder;
+ }
+
+ public static AnisetteServerTesterService serverTester(final CronetEngine engine) {
+ return serverTesterFactory.apply(engine);
+ }
+
+ @VisibleForTesting
+ public static void replaceServerTester(final AnisetteServerTesterService replacement) {
+ serverTesterFactory = engine -> replacement;
+ }
+
+ public static AnisetteSource anisette(
+ final Context context, final UserSettings settings, final boolean hasExistingSession) {
+ return anisetteFactory.create(context, settings, hasExistingSession);
+ }
+
+ @VisibleForTesting
+ public static void replaceAuthService(final AppleAuthService replacement) {
+ authService = replacement;
+ }
+
+ @VisibleForTesting
+ public static void replaceHardwareDescriber(final HardwareDescriber replacement) {
+ hardwareDescriber = replacement;
+ }
+
+ @VisibleForTesting
+ public static void replaceAccessoryMacResolver(final AccessoryMacResolver replacement) {
+ accessoryMacResolver = replacement;
+ }
+
+ @VisibleForTesting
+ public static void replaceAccessorySoundTrigger(final AccessorySoundTrigger replacement) {
+ accessorySoundTrigger = replacement;
+ }
+
+ @VisibleForTesting
+ public static void replaceLogRedactor(final LogRedactor replacement) {
+ logRedactor = replacement;
+ }
+
+ @VisibleForTesting
+ public static void replaceBundleBuilder(final BundleBuilder replacement) {
+ bundleBuilder = replacement;
+ }
+
+ @VisibleForTesting
+ public static void replaceAnisette(final Function replacement) {
+ anisetteFactory = (context, settings, hasSession) -> replacement.apply(settings);
+ }
+
+ /** Put the real ones back. Call from a teardown, or the next test inherits a fake. */
+ @VisibleForTesting
+ public static void reset() {
+ authService = new PythonAppleAuthService();
+ anisetteFactory = LocalAnisette::new;
+ serverTesterFactory = AnisetteServerTesterService::new;
+ hardwareDescriber = new ChaquopyHardwareDescriber();
+ accessoryMacResolver = new ChaquopyAccessoryMacResolver();
+ accessorySoundTrigger = new BleAccessorySoundTrigger(accessoryMacResolver);
+ logRedactor = new ChaquopyLogRedactor();
+ bundleBuilder = new ChaquopyBundleBuilder();
+ icloudFactory = AppDependencies::openRealICloud;
+ geocoderFactory = (context, locale) ->
+ AddressLookup.through(new Geocoder(context, locale));
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java
new file mode 100644
index 00000000..c084ef98
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java
@@ -0,0 +1,56 @@
+package dev.wander.android.opentagviewer.python;
+
+import android.util.Log;
+
+import com.chaquo.python.PyObject;
+import com.chaquo.python.Python;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * The real resolver: calls {@code main.py:currentMacAddresses}, which delegates to
+ * {@code RollingKeyPairSource.current_mac_addresses} in FindMy.py.
+ *
+ * The Python runtime is resolved lazily per call rather than held as a field, so constructing
+ * this does not require Chaquopy to have started - same reasoning as
+ * {@link ChaquopyHardwareDescriber}.
+ *
+ *
Blocking, and starts an interpreter. Never call this on the main thread; the {@code
+ * ble} package that uses it does so on an Rx scheduler.
+ */
+public class ChaquopyAccessoryMacResolver implements AccessoryMacResolver {
+ private static final String TAG = ChaquopyAccessoryMacResolver.class.getSimpleName();
+ private static final String MODULE_MAIN = "main";
+
+ @Override
+ public List currentMacAddresses(final String accessoryJson) {
+ if (accessoryJson == null || accessoryJson.isEmpty()) {
+ // Not yet backfilled from the legacy plist - see OwnedBeacon.accessoryJson. A real
+ // state, not a failure, so this reports it the same way Python does: nothing found.
+ return Collections.emptyList();
+ }
+
+ try {
+ final var module = Python.getInstance().getModule(MODULE_MAIN);
+ final PyObject returned = module.callAttr("currentMacAddresses", accessoryJson);
+
+ if (returned == null) {
+ Log.w(TAG, "currentMacAddresses returned None (check python logs for details)");
+ return Collections.emptyList();
+ }
+
+ final List macs = new ArrayList<>();
+ for (final PyObject mac : returned.asList()) {
+ macs.add(mac.toString());
+ }
+ return macs;
+ } catch (final Exception e) {
+ // Either Python has not started, or the accessory JSON could not be read. Neither
+ // is worth failing the caller over: it reads as "nothing to match against yet".
+ Log.w(TAG, "currentMacAddresses failed", e);
+ return Collections.emptyList();
+ }
+ }
+}
diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py
index 9ff035ce..3012bbbf 100644
--- a/app/src/main/python/main.py
+++ b/app/src/main/python/main.py
@@ -1017,6 +1017,30 @@ def accessoryFromJson(accessoryJson: str) -> StoredAccessory:
return accessoryType.from_json(cast(Any, mapping))
+def currentMacAddresses(accessoryJson: str) -> list[str] | None:
+ """
+ The BLE MAC address(es) this accessory might currently be advertising.
+
+ Lets Java recognise an owned accessory's own advertisement in a BLE scan, so it can be
+ triggered directly (playing a sound) without going through Apple's Find My network - the
+ same thing Find My itself does when a tag is close enough to reach over Bluetooth.
+
+ Delegates to `RollingKeyPairSource.current_mac_addresses`, added to the pinned FindMy.py
+ fork alongside this feature: it spans the accessory's `get_min_index`/`get_max_index`
+ range for *now* rather than a single index, to account for rollover uncertainty since the
+ last observed alignment.
+
+ Returns None on failure so Java can decide how to recover - a missing or unreadable
+ accessory is worth telling apart from "no keys", which would be an empty list.
+ """
+ try:
+ accessory = accessoryFromJson(accessoryJson)
+ return sorted(accessory.current_mac_addresses())
+ except Exception:
+ print(f"currentMacAddresses failed: {traceback.format_exc()}")
+ return None
+
+
def _isAlignmentWide(accessory: StoredAccessory, start, end) -> int:
"""Width of the key-index range a history fetch would search, or 0 if unknown."""
try:
diff --git a/app/src/main/res/menu/device_info_menu.xml b/app/src/main/res/menu/device_info_menu.xml
index 374a9aa9..d0bd9774 100644
--- a/app/src/main/res/menu/device_info_menu.xml
+++ b/app/src/main/res/menu/device_info_menu.xml
@@ -5,6 +5,10 @@
android:id="@+id/device_location_history"
android:title="@string/location_history" />
+
+
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 882532ac..bf402ff0 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -309,4 +309,12 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Vom Apple-Konto aktualisieren
Auf dem Stand deines Apple-Kontos
Dein Apple-Konto war gerade nicht erreichbar. An deinen Tags hat sich nichts geändert.
+ In der Nähe klingeln lassen
+ Suche in der Nähe per Bluetooth…
+ Ton wird jetzt abgespielt.
+ Nicht in der Nähe gefunden. Komm näher und versuche es erneut.
+ Verbunden, aber dieses Zubehör hat keinen Ton-Dienst.
+ Die aktuelle Adresse dieses Zubehörs konnte nicht berechnet werden.
+ Für das Klingeln in der Nähe wird die Bluetooth-Berechtigung benötigt.
+ Ton konnte nicht abgespielt werden. Versuche es erneut.
\ No newline at end of file
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index a0c47dc2..5866788a 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -309,4 +309,12 @@ You can set this up now, or any time later from Settings.
Refresh from Apple account
Up to date with your Apple account
Could not reach your Apple account just now. Your tags are unchanged.
+ Play Sound Nearby
+ Searching nearby over Bluetooth…
+ Playing sound now.
+ Not found nearby. Move closer and try again.
+ Connected, but this accessory has no sound service.
+ Could not compute this accessory\'s current address.
+ Bluetooth permission is needed to play the sound nearby.
+ Could not play the sound. Try again.
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index cb14593f..3b86e604 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -309,4 +309,12 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.<
Actualiser depuis le compte Apple
À jour avec votre compte Apple
Impossible de joindre votre compte Apple pour le moment. Vos tags sont inchangés.
+ Faire sonner à proximité
+ Recherche à proximité via Bluetooth…
+ Lecture du son en cours.
+ Introuvable à proximité. Rapprochez-vous et réessayez.
+ Connecté, mais cet accessoire n\'a pas de service sonore.
+ Impossible de calculer l\'adresse actuelle de cet accessoire.
+ L\'autorisation Bluetooth est nécessaire pour faire sonner l\'appareil à proximité.
+ Impossible de jouer le son. Réessayez.
\ No newline at end of file
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index a765b95c..041d0475 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -309,4 +309,12 @@
Apple アカウントから更新
Apple アカウントと同じ状態になりました
いま Apple アカウントに接続できませんでした。タグはそのままです。
+ 近くで音を鳴らす
+ Bluetoothで近くを検索中…
+ 音を再生しています。
+ 近くに見つかりませんでした。近づいてもう一度お試しください。
+ 接続しましたが、このアクセサリにはサウンド機能がありません。
+ このアクセサリの現在のアドレスを計算できませんでした。
+ 近くで音を鳴らすにはBluetoothの権限が必要です。
+ 音を再生できませんでした。もう一度お試しください。
\ No newline at end of file
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index f85f3d0a..ea4fead1 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -309,4 +309,12 @@
Apple 계정에서 새로 고침
Apple 계정과 동기화되었습니다
지금은 Apple 계정에 연결하지 못했습니다. 태그는 그대로입니다.
+ 근처에서 소리 재생
+ 블루투스로 근처 검색 중…
+ 소리를 재생하고 있습니다.
+ 근처에서 찾을 수 없습니다. 더 가까이 가서 다시 시도하세요.
+ 연결되었지만 이 액세서리에는 사운드 서비스가 없습니다.
+ 이 액세서리의 현재 주소를 계산할 수 없습니다.
+ 근처에서 소리를 재생하려면 블루투스 권한이 필요합니다.
+ 소리를 재생할 수 없습니다. 다시 시도하세요.
\ No newline at end of file
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 21138c61..91e769d6 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -309,4 +309,12 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen.
Vernieuwen vanaf Apple-account
Bijgewerkt met je Apple-account
Je Apple-account was even niet bereikbaar. Je tags zijn ongewijzigd.
+ In de buurt laten piepen
+ Zoeken in de buurt via Bluetooth…
+ Geluid wordt nu afgespeeld.
+ Niet in de buurt gevonden. Kom dichterbij en probeer het opnieuw.
+ Verbonden, maar dit accessoire heeft geen geluidsservice.
+ Kan het huidige adres van dit accessoire niet berekenen.
+ Bluetooth-toestemming is nodig om het geluid in de buurt af te spelen.
+ Kan het geluid niet afspelen. Probeer het opnieuw.
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index ffd04f46..0f17b3d9 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -309,4 +309,12 @@
Обновить из учётной записи Apple
Данные соответствуют учётной записи Apple
Сейчас не удалось связаться с учётной записью Apple. Метки не изменились.
+ Издать звук поблизости
+ Поиск поблизости через Bluetooth…
+ Воспроизведение звука…
+ Не найдено поблизости. Подойдите ближе и попробуйте снова.
+ Подключено, но у этого аксессуара нет звуковой службы.
+ Не удалось вычислить текущий адрес этого аксессуара.
+ Для воспроизведения звука поблизости требуется разрешение Bluetooth.
+ Не удалось воспроизвести звук. Попробуйте снова.
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index d9515156..9e9df214 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -309,4 +309,12 @@
从 Apple 账户刷新
已与你的 Apple 账户同步
此刻无法连接你的 Apple 账户。标签没有变化。
+ 就近响铃
+ 正在通过蓝牙搜索附近设备…
+ 正在播放声音。
+ 附近未找到。请靠近后重试。
+ 已连接,但此配件没有声音服务。
+ 无法计算此配件当前的地址。
+ 就近响铃需要蓝牙权限。
+ 无法播放声音。请重试。
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 40bd69d9..b54a366a 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -309,4 +309,12 @@
從 Apple 帳戶重新整理
已與你的 Apple 帳戶同步
此刻無法連線到你的 Apple 帳戶。標籤沒有變化。
+ 就近響鈴
+ 正在透過藍牙搜尋附近裝置…
+ 正在播放聲音。
+ 附近未找到。請靠近後再試一次。
+ 已連線,但此配件沒有聲音服務。
+ 無法計算此配件目前的位址。
+ 就近響鈴需要藍牙權限。
+ 無法播放聲音。請重試。
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a4b20e8e..4817b4f0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -341,4 +341,12 @@ You can set this up now, or any time later from Settings.
Refresh from Apple account
Up to date with your Apple account
Could not reach your Apple account just now. Your tags are unchanged.
+ Play Sound Nearby
+ Searching nearby over Bluetooth…
+ Playing sound now.
+ Not found nearby. Move closer and try again.
+ Connected, but this accessory has no sound service.
+ Could not compute this accessory\'s current address.
+ Bluetooth permission is needed to play the sound nearby.
+ Could not play the sound. Try again.
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessoryMatcherTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessoryMatcherTest.java
new file mode 100644
index 00000000..3abb953d
--- /dev/null
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessoryMatcherTest.java
@@ -0,0 +1,52 @@
+package dev.wander.android.opentagviewer.ble;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.Set;
+
+/**
+ * A JVM test on purpose: no Android in {@link BleAccessoryMatcher}, so this runs in the fast
+ * suite rather than needing an emulator - see its class doc for why the comparison was pulled
+ * out this way in the first place.
+ */
+public class BleAccessoryMatcherTest {
+
+ private static final String CANDIDATE = "AA:BB:CC:DD:EE:FF";
+
+ @Test
+ public void matchesAnExactCandidate() {
+ assertTrue(BleAccessoryMatcher.matches(CANDIDATE, Set.of(CANDIDATE)));
+ }
+
+ @Test
+ public void matchesRegardlessOfCase() {
+ assertTrue(BleAccessoryMatcher.matches(
+ CANDIDATE.toLowerCase(), Set.of(CANDIDATE.toUpperCase())));
+ assertTrue(BleAccessoryMatcher.matches(
+ CANDIDATE.toUpperCase(), Set.of(CANDIDATE.toLowerCase())));
+ }
+
+ @Test
+ public void matchesOneOfSeveralCandidates() {
+ assertTrue(BleAccessoryMatcher.matches(
+ CANDIDATE, Set.of("11:22:33:44:55:66", CANDIDATE, "77:88:99:AA:BB:CC")));
+ }
+
+ @Test
+ public void doesNotMatchAnUnrelatedAddress() {
+ assertFalse(BleAccessoryMatcher.matches("11:22:33:44:55:66", Set.of(CANDIDATE)));
+ }
+
+ @Test
+ public void doesNotMatchAgainstAnEmptyCandidateSet() {
+ assertFalse(BleAccessoryMatcher.matches(CANDIDATE, Set.of()));
+ }
+
+ @Test
+ public void doesNotMatchANullScannedAddress() {
+ assertFalse(BleAccessoryMatcher.matches(null, Set.of(CANDIDATE)));
+ }
+}
diff --git a/app/src/test/python/test_main.py b/app/src/test/python/test_main.py
index facdff4a..87283c15 100644
--- a/app/src/test/python/test_main.py
+++ b/app/src/test/python/test_main.py
@@ -165,6 +165,46 @@ def test_json_that_is_not_an_accessory_is_refused(blob):
main.accessoryFromJson(blob)
+# --------------------------------------------------------------------------
+# currentMacAddresses
+#
+# The BLE MAC address(es) an accessory might currently be advertising - what
+# dev.wander.android.opentagviewer.ble matches a scan result against to trigger an owned
+# accessory's sound directly, without going through Apple's Find My network.
+# --------------------------------------------------------------------------
+
+_MAC_RE = re.compile(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$")
+
+
+def test_current_mac_addresses_for_a_self_generated_tag():
+ macs = main.currentMacAddresses(json.dumps(_CUSTOM_ACCESSORY))
+
+ assert macs is not None
+ assert len(macs) > 0
+ for mac in macs:
+ assert _MAC_RE.match(mac), f"{mac!r} is not a MAC address"
+
+
+def test_current_mac_addresses_is_deterministic_for_a_fixed_key_tag():
+ """A self-generated tag's keys don't rotate, so asking twice must agree - unlike an Apple-
+ paired one, where this is only true at the exact same instant (rollover happens meanwhile)."""
+ once = main.currentMacAddresses(json.dumps(_CUSTOM_ACCESSORY))
+ twice = main.currentMacAddresses(json.dumps(_CUSTOM_ACCESSORY))
+
+ assert once == twice
+
+
+def test_current_mac_addresses_returns_none_on_garbage():
+ """Failure must be None, not an exception - see AccessoryMacResolver's Java contract, which
+ reads None the same way as an empty answer: nothing to scan for yet."""
+ assert main.currentMacAddresses("not an accessory at all") is None
+
+
+def test_current_mac_addresses_refuses_an_unknown_accessory_type():
+ assert main.currentMacAddresses(
+ json.dumps({"type": "something_from_the_future"})) is None
+
+
def test_convertPlistToJson_returns_none_on_garbage():
"""Failure must be None, not an exception - the caller retries later."""
assert main.convertPlistToJson("not a plist at all") is None
From 972d9f0ba2162b37f992b9c4188093376e1009d1 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sat, 22 Aug 2026 14:03:01 +0200
Subject: [PATCH 02/61] Add continuous ping: repeated scan+trigger, toggled
from the map itself
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).
---
.../ui/maps/TagCardLayoutTest.java | 93 +++++++++++---
.../android/opentagviewer/MapsActivity.java | 117 +++++++++++++++++-
.../ble/AccessorySoundTrigger.java | 12 ++
.../ble/BleAccessorySoundTrigger.java | 20 +++
.../data/model/BeaconInformation.java | 9 ++
.../opentagviewer/ui/maps/TagCardHelper.java | 23 ++++
.../util/parse/BeaconDataParser.java | 3 +-
app/src/main/res/layout/maps_tag_card.xml | 2 +-
app/src/main/res/values-de/strings.xml | 1 +
app/src/main/res/values-en/strings.xml | 1 +
app/src/main/res/values-fr/strings.xml | 1 +
app/src/main/res/values-ja/strings.xml | 1 +
app/src/main/res/values-ko/strings.xml | 1 +
app/src/main/res/values-nl/strings.xml | 1 +
app/src/main/res/values-ru/strings.xml | 1 +
app/src/main/res/values-zh-rCN/strings.xml | 1 +
app/src/main/res/values-zh-rTW/strings.xml | 1 +
app/src/main/res/values/strings.xml | 1 +
18 files changed, 270 insertions(+), 19 deletions(-)
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java
index 75d9135a..f863fd7c 100644
--- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java
@@ -471,17 +471,16 @@ private List measureIconVariantHeights(final float fontScale) {
* card's height is fixed by its shortest neighbour - so the failure is a row of icons with
* the words clipped away, on one card out of four.
*
- * Ring is not among them, and that is deliberate. Its container is
- * {@code visibility="gone"} in the layout because the feature does not exist - FindMy.py has
- * no ring implementation - so it measures nothing. Pinned separately below rather than
- * quietly skipped here.
+ *
Ring is included alongside the other three - see
+ * {@code dev.wander.android.opentagviewer.ble} for what is behind it now.
*/
@Test
public void everyActionOnTheCardStillHasRoomWithTheWorstContent() {
- final int[][] sizes = new int[3][2];
+ final int[][] sizes = new int[4][2];
final int[] buttonIds = {
R.id.device_history_button_container,
R.id.device_refresh_button_container,
+ R.id.device_ring_button_container,
R.id.device_more_button_container,
};
@@ -514,7 +513,10 @@ public void everyActionOnTheCardStillHasRoomWithTheWorstContent() {
// They share the row, so a card that has run out of width shows up as one of them being
// visibly smaller than the rest rather than as anything failing.
- final int widest = Math.max(Math.max(sizes[0][0], sizes[1][0]), sizes[2][0]);
+ int widest = 0;
+ for (final int[] size : sizes) {
+ widest = Math.max(widest, size[0]);
+ }
for (int i = 0; i < buttonIds.length; i++) {
assertTrue("button " + i + " is " + sizes[i][0] + "px against a widest of " + widest
+ ", so the row is no longer sharing the width evenly",
@@ -523,16 +525,17 @@ public void everyActionOnTheCardStillHasRoomWithTheWorstContent() {
}
/**
- * Ring is hidden, because there is nothing behind it.
+ * Ring is shown by default, at rest.
*
- *
{@code onClickRing} logs and returns: FindMy.py cannot ring an accessory, so the
- * control exists in the layout and is switched off. This is here so that stops being true
- * on purpose rather than by accident - a stray edit making it visible ships a button that
- * does nothing at all, which is worse than not offering it.
+ *
It used to be {@code visibility="gone"} because nothing implemented it. Now
+ * {@code MapsActivity#onClickRing} does (continuous ping over BLE, see
+ * {@code dev.wander.android.opentagviewer.ble}), so this is the opposite pin from before: a
+ * stray edit hiding it again ships a card silently missing an action, rather than a card
+ * offering one that does nothing.
*/
@Test
- public void theRingButtonStaysHiddenWhileThereIsNothingBehindIt() {
- final int[] visibility = {View.VISIBLE};
+ public void theRingButtonIsShownAtRestByDefault() {
+ final int[] visibility = {View.GONE};
getInstrumentation().runOnMainSync(() -> {
final FrameLayout card = (FrameLayout) LayoutInflater.from(this.context)
@@ -540,7 +543,67 @@ public void theRingButtonStaysHiddenWhileThereIsNothingBehindIt() {
visibility[0] = card.findViewById(R.id.device_ring_button_container).getVisibility();
});
- assertEquals("Ring is showing, but nothing implements it - see MapsActivity#onClickRing",
- View.GONE, visibility[0]);
+ assertEquals("Ring is hidden, but MapsActivity#onClickRing now implements it",
+ View.VISIBLE, visibility[0]);
+ }
+
+ /** The label at rest, before anyone has tapped it - see {@link TagCardHelper#toggleRingActive}. */
+ @Test
+ public void theRingButtonStartsLabelledRing() {
+ final String[] text = {null};
+
+ getInstrumentation().runOnMainSync(() -> {
+ final FrameLayout card = (FrameLayout) LayoutInflater.from(this.context)
+ .inflate(R.layout.maps_tag_card, null);
+ text[0] = ((TextView) card.findViewById(R.id.ringText)).getText().toString();
+ });
+
+ assertEquals(this.context.getString(R.string.do_ring), text[0]);
+ }
+
+ /**
+ * {@link TagCardHelper#toggleRingActive} is what MapsActivity calls on tap, and on stop.
+ *
+ *
Round-tripped in one test rather than two, because the failure that matters is the
+ * button getting stuck in one state - which only shows up by going there and back.
+ */
+ @Test
+ public void toggleRingActiveSwapsTheLabelBothWays() {
+ final String[] activeText = {null};
+ final String[] inactiveAgainText = {null};
+
+ getInstrumentation().runOnMainSync(() -> {
+ final FrameLayout card = (FrameLayout) LayoutInflater.from(this.context)
+ .inflate(R.layout.maps_tag_card, null);
+
+ TagCardHelper.toggleRingActive(card, true);
+ activeText[0] = ((TextView) card.findViewById(R.id.ringText)).getText().toString();
+
+ TagCardHelper.toggleRingActive(card, false);
+ inactiveAgainText[0] = ((TextView) card.findViewById(R.id.ringText)).getText().toString();
+ });
+
+ assertEquals(this.context.getString(R.string.stop_ringing), activeText[0]);
+ assertEquals(this.context.getString(R.string.do_ring), inactiveAgainText[0]);
+ }
+
+ /**
+ * {@link TagCardHelper#setRingLabel} is what continuous ping updates between taps -
+ * "Scanning...", "Connecting...", "Sending..." - without touching the icon or tint
+ * {@link TagCardHelper#toggleRingActive} owns. See {@code MapsActivity#handleContinuousPingUpdate}.
+ */
+ @Test
+ public void setRingLabelChangesOnlyTheText() {
+ final String[] text = {null};
+
+ getInstrumentation().runOnMainSync(() -> {
+ final FrameLayout card = (FrameLayout) LayoutInflater.from(this.context)
+ .inflate(R.layout.maps_tag_card, null);
+
+ TagCardHelper.setRingLabel(card, "Scanning…");
+ text[0] = ((TextView) card.findViewById(R.id.ringText)).getText().toString();
+ });
+
+ assertEquals("Scanning…", text[0]);
}
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index a71f1a0f..5b5242df 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -128,9 +128,12 @@
import dev.wander.android.opentagviewer.util.rx.AccountReadPolicy;
import dev.wander.android.opentagviewer.util.rx.RefreshPolicy;
import dev.wander.android.opentagviewer.util.rx.RxFlows;
+import dev.wander.android.opentagviewer.ble.BlePermissions;
+import dev.wander.android.opentagviewer.ble.BleSoundTriggerResult;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
import lombok.Data;
@@ -141,6 +144,7 @@ public class MapsActivity extends AppCompatActivity implements IMapProvider.OnMa
private static final String TAG = MapsActivity.class.getSimpleName();
private static final int LOCATION_PERMISSION_REQUEST_CODE = 1;
+ private static final int RING_PERMISSION_REQUEST_CODE = 2;
private static final int GOOGLE_LOGO_PADDING_BOTTOM_PX = 40;
@@ -195,6 +199,18 @@ public class MapsActivity extends AppCompatActivity implements IMapProvider.OnMa
private final Map beacons = new ConcurrentHashMap<>();
+ /** The beaconId continuous ping is currently running for, or null if it is off. Only one
+ * runs at a time - see {@link #onClickRing}. */
+ private String continuousPingBeaconId;
+
+ /** The in-flight continuous ping loop, so leaving the screen stops the radio work rather
+ * than leaving it running in the background with nothing left to show its state. */
+ private Disposable continuousPingDisposable;
+
+ /** Which tag's ring button asked for BLE permission, so the result callback - which carries
+ * no context of its own - knows what to start once it is granted. */
+ private String ringPermissionRequestBeaconId;
+
/** Location history plus the "can this be drawn" rule. See BeaconLocationHistoryTest. */
private final BeaconLocationHistory beaconLocations = new BeaconLocationHistory();
@@ -562,7 +578,13 @@ protected void onResume() {
@Override
protected void onDestroy() {
super.onDestroy();
-
+
+ // Otherwise continuous ping keeps scanning/connecting in the background with no card
+ // left to show it is running, or a way to stop it short of force-closing the app.
+ if (this.continuousPingDisposable != null && !this.continuousPingDisposable.isDisposed()) {
+ this.continuousPingDisposable.dispose();
+ }
+
// 调用高德地图的生命周期方法
if (this.mapProvider instanceof AMapProvider) {
((AMapProvider) this.mapProvider).onDestroy();
@@ -1272,8 +1294,82 @@ public void onClickRefresh(View view) {
});
}
+ /**
+ * Toggle continuous ping (repeated scan + play-sound-nearby, see
+ * {@code AccessorySoundTrigger#playSoundContinuously}) for this card's tag - on until tapped
+ * again, unlike {@code DeviceInfoActivity}'s one-shot "Play Sound Nearby".
+ *
+ * Only one tag at a time: starting it for a different tag stops whichever was running,
+ * since it is one Bluetooth radio and one thing to listen for.
+ */
public void onClickRing(View view) {
- Log.i(TAG, "The ring button was clicked");
+ Log.d(TAG, "The ring button was clicked");
+
+ final String beaconId = this.dynamicCardsForTag.entrySet()
+ .stream().filter(kvp -> kvp.getValue().findViewById(R.id.device_ring_button_container) == view)
+ .map(Map.Entry::getKey)
+ .findFirst()
+ .orElseThrow(() -> new RuntimeException("Click ring event was raised by a Beacon Device's card, but the beaconId could not be found for it!"));
+
+ final boolean wasRunningForThisTag = beaconId.equals(this.continuousPingBeaconId);
+ this.stopContinuousPing();
+ if (wasRunningForThisTag) {
+ return;
+ }
+
+ if (!BlePermissions.granted(this)) {
+ Log.d(TAG, "Requesting BLE permission(s) before starting continuous ping for beaconId=" + beaconId);
+ this.ringPermissionRequestBeaconId = beaconId;
+ ActivityCompat.requestPermissions(this, BlePermissions.required(), RING_PERMISSION_REQUEST_CODE);
+ return;
+ }
+
+ this.startContinuousPing(beaconId);
+ }
+
+ private void startContinuousPing(final String beaconId) {
+ final BeaconData beaconData = this.beacons.get(beaconId);
+ if (beaconData == null) {
+ Log.w(TAG, "Cannot start continuous ping: no loaded data for beaconId=" + beaconId);
+ return;
+ }
+ final String accessoryJson = beaconData.getInfo().getOwnedBeaconAccessoryJson();
+
+ this.continuousPingBeaconId = beaconId;
+ final FrameLayout container = this.dynamicCardsForTag.get(beaconId);
+ if (container != null) {
+ TagCardHelper.toggleRingActive(container, true);
+ }
+
+ this.continuousPingDisposable = AppDependencies.accessorySoundTrigger()
+ .playSoundContinuously(this.getApplicationContext(), accessoryJson)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ (BleSoundTriggerResult result) -> Log.d(TAG, "Continuous ping attempt for beaconId="
+ + beaconId + ": " + result.getStatus()
+ + (result.getMessage() == null ? "" : " (" + result.getMessage() + ")")),
+ error -> {
+ // playSoundContinuously's contract is to never error onto this path -
+ // see its interface doc - so reaching here means a bug in that
+ // contract, not an ordinary "not found nearby" or "no permission".
+ Log.e(TAG, "Continuous ping stopped unexpectedly for beaconId=" + beaconId, error);
+ this.stopContinuousPing();
+ });
+ }
+
+ private void stopContinuousPing() {
+ if (this.continuousPingDisposable != null && !this.continuousPingDisposable.isDisposed()) {
+ this.continuousPingDisposable.dispose();
+ }
+ this.continuousPingDisposable = null;
+
+ if (this.continuousPingBeaconId != null) {
+ final FrameLayout container = this.dynamicCardsForTag.get(this.continuousPingBeaconId);
+ if (container != null) {
+ TagCardHelper.toggleRingActive(container, false);
+ }
+ }
+ this.continuousPingBeaconId = null;
}
public void onClickMoreForDevice(View view) {
@@ -2318,6 +2414,23 @@ private void performNativePermissionRequest() {
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ if (requestCode == RING_PERMISSION_REQUEST_CODE) {
+ final String beaconId = this.ringPermissionRequestBeaconId;
+ this.ringPermissionRequestBeaconId = null;
+
+ // Re-checked against BlePermissions.granted rather than the grantResults array
+ // directly - one place decides what "enough" means, matching DeviceInfoActivity's
+ // own permission flow and BlePermissions' class doc.
+ if (beaconId != null && BlePermissions.granted(this)) {
+ Log.i(TAG, "BLE permission granted; starting continuous ping for beaconId=" + beaconId);
+ this.startContinuousPing(beaconId);
+ } else {
+ Log.i(TAG, "BLE permission refused; not starting continuous ping");
+ Toast.makeText(this, R.string.play_sound_permission_denied, LENGTH_LONG).show();
+ }
+ return;
+ }
+
if (requestCode != LOCATION_PERMISSION_REQUEST_CODE) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
return;
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java
index 3ca7e238..51e618ad 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java
@@ -2,6 +2,7 @@
import android.content.Context;
+import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
/**
@@ -23,4 +24,15 @@ public interface AccessorySoundTrigger {
* one lambda.
*/
Single playSound(Context context, String accessoryJson);
+
+ /**
+ * Repeats {@link #playSound} - scan, trigger (or fail), pause, scan again - for as long as
+ * the returned {@link Observable} stays subscribed. For walking toward a tag by ear: a
+ * single {@link #playSound} only ever gets one chance to be in range at the moment it scans.
+ *
+ * Never errors, same as {@link #playSound} - each attempt's outcome is an item, not a
+ * terminal signal, so one failed attempt (e.g. briefly out of range) does not end the loop.
+ * Dispose the subscription to stop.
+ */
+ Observable playSoundContinuously(Context context, String accessoryJson);
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
index 63c4d4d5..dede76d8 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
@@ -5,8 +5,10 @@
import java.util.HashSet;
import java.util.List;
import java.util.Set;
+import java.util.concurrent.TimeUnit;
import dev.wander.android.opentagviewer.python.AccessoryMacResolver;
+import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
@@ -30,6 +32,13 @@ public class BleAccessorySoundTrigger implements AccessorySoundTrigger {
*/
private static final long SCAN_TIMEOUT_MS = 15_000L;
+ /**
+ * How long {@link #playSoundContinuously} waits after one attempt (found or not) before the
+ * next. Short enough to feel responsive while walking toward a tag; long enough that a
+ * successful AirTag chirp (a few seconds) has time to finish before the next scan starts.
+ */
+ private static final long CONTINUOUS_PING_PAUSE_MS = 4_000L;
+
private final AccessoryMacResolver macResolver;
public BleAccessorySoundTrigger(final AccessoryMacResolver macResolver) {
@@ -59,6 +68,17 @@ public Single playSound(final Context context, final Stri
}).subscribeOn(Schedulers.io());
}
+ @Override
+ public Observable playSoundContinuously(
+ final Context context, final String accessoryJson) {
+ // playSound is a Single, so it completes after its one item; repeatWhen re-subscribes
+ // it once the delayed completion signal fires, which is what turns "do it once" into
+ // "do it again after a pause", forever, until the subscriber disposes.
+ return playSound(context, accessoryJson)
+ .toObservable()
+ .repeatWhen(completed -> completed.delay(CONTINUOUS_PING_PAUSE_MS, TimeUnit.MILLISECONDS));
+ }
+
private static BleSoundTriggerResult asResult(final Throwable error) {
if (error instanceof NearbyAccessoryScanner.NotNearbyException) {
return new BleSoundTriggerResult(BleSoundTriggerStatus.NOT_NEARBY, null, error.getMessage());
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/data/model/BeaconInformation.java b/app/src/main/java/dev/wander/android/opentagviewer/data/model/BeaconInformation.java
index 2c76f48f..cb3d5a74 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/data/model/BeaconInformation.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/data/model/BeaconInformation.java
@@ -80,6 +80,15 @@ public class BeaconInformation {
* Sourced from the primary file: {@code OwnedBeacons/.plist}
*/
private final String ownedBeaconPlistRaw;
+ /**
+ * Serialized FindMyAccessory/FixedRollingKeyPairAccessory state (JSON) - see
+ * {@code OwnedBeacon.accessoryJson}. Null for a row imported under FindMy 0.7.6 that has not
+ * yet been backfilled from {@link #ownedBeaconPlistRaw}.
+ *
+ * What {@code dev.wander.android.opentagviewer.ble} resolves a current BLE MAC address
+ * from, to recognise this accessory's own advertisement in a scan.
+ */
+ private final String ownedBeaconAccessoryJson;
/**
* {@code 0} or {@code 1} (?)
*
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/TagCardHelper.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/TagCardHelper.java
index 112d11c4..fab9a23e 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/TagCardHelper.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/TagCardHelper.java
@@ -3,10 +3,13 @@
import static android.view.View.GONE;
import static android.view.View.VISIBLE;
+import android.content.res.ColorStateList;
import android.util.Log;
import android.widget.FrameLayout;
import android.widget.ImageView;
+import android.widget.TextView;
+import com.google.android.material.color.MaterialColors;
import com.google.android.material.progressindicator.CircularProgressIndicator;
import java.util.Map;
@@ -36,6 +39,26 @@ public static void toggleRefreshLoading(FrameLayout container, boolean isLoading
}
}
+ /**
+ * Shows whether continuous ping (repeated scan + play-sound-nearby) is running for this
+ * card's tag - the icon becomes a stop glyph, tinted with the theme's error colour so it
+ * reads as "tap to stop" at a glance, and the label swaps to match.
+ */
+ public static void toggleRingActive(FrameLayout container, boolean active) {
+ try {
+ ImageView icon = container.findViewById(R.id.perform_ring_icon);
+ TextView label = container.findViewById(R.id.ringText);
+
+ icon.setImageResource(active ? R.drawable.close_24px : R.drawable.volume_24);
+ icon.setImageTintList(ColorStateList.valueOf(active
+ ? MaterialColors.getColor(container, com.google.android.material.R.attr.colorError)
+ : MaterialColors.getColor(container, com.google.android.material.R.attr.colorOnSurfaceVariant)));
+ label.setText(active ? R.string.stop_ringing : R.string.do_ring);
+ } catch (Exception e) {
+ Log.e(TAG, "Failure while trying to toggle the ring button's active state", e);
+ }
+ }
+
public static void toggleRefreshLoadingAll(Map containers, boolean isLoading) {
try {
for (var frameLayout : containers.values()) {
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BeaconDataParser.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BeaconDataParser.java
index 9c4e3802..f3abdd98 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BeaconDataParser.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BeaconDataParser.java
@@ -181,7 +181,8 @@ public static List parse(final List rawBeaconData
.ignoredAt(beaconData.getOwnedBeaconInfo().ignoredAt)
.fruitlessScans(beaconData.getOwnedBeaconInfo().fruitlessScans)
.lastScanAt(beaconData.getOwnedBeaconInfo().lastScanAt)
- .ownedBeaconPlistRaw(ownedBeaconPList);
+ .ownedBeaconPlistRaw(ownedBeaconPList)
+ .ownedBeaconAccessoryJson(beaconData.getOwnedBeaconInfo().accessoryJson);
if (userOverrides != null) {
// configure user overrides too
diff --git a/app/src/main/res/layout/maps_tag_card.xml b/app/src/main/res/layout/maps_tag_card.xml
index be1664cd..00bdb8f3 100644
--- a/app/src/main/res/layout/maps_tag_card.xml
+++ b/app/src/main/res/layout/maps_tag_card.xml
@@ -274,7 +274,7 @@
android:onClick="onClickRing"
android:orientation="vertical"
android:padding="5dp"
- android:visibility="gone">
+ android:visibility="visible">
Die aktuelle Adresse dieses Zubehörs konnte nicht berechnet werden.
Für das Klingeln in der Nähe wird die Bluetooth-Berechtigung benötigt.
Ton konnte nicht abgespielt werden. Versuche es erneut.
+ Stopp
\ No newline at end of file
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index 5866788a..29a6fc97 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -317,4 +317,5 @@ You can set this up now, or any time later from Settings.
Could not compute this accessory\'s current address.
Bluetooth permission is needed to play the sound nearby.
Could not play the sound. Try again.
+ Stop
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 3b86e604..1c55491a 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -317,4 +317,5 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.<
Impossible de calculer l\'adresse actuelle de cet accessoire.
L\'autorisation Bluetooth est nécessaire pour faire sonner l\'appareil à proximité.
Impossible de jouer le son. Réessayez.
+ Arrêter
\ No newline at end of file
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 041d0475..1e5357fc 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -317,4 +317,5 @@
このアクセサリの現在のアドレスを計算できませんでした。
近くで音を鳴らすにはBluetoothの権限が必要です。
音を再生できませんでした。もう一度お試しください。
+ 停止
\ No newline at end of file
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index ea4fead1..f5cf2092 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -317,4 +317,5 @@
이 액세서리의 현재 주소를 계산할 수 없습니다.
근처에서 소리를 재생하려면 블루투스 권한이 필요합니다.
소리를 재생할 수 없습니다. 다시 시도하세요.
+ 중지
\ No newline at end of file
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 91e769d6..6666f1ef 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -317,4 +317,5 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen.
Kan het huidige adres van dit accessoire niet berekenen.
Bluetooth-toestemming is nodig om het geluid in de buurt af te spelen.
Kan het geluid niet afspelen. Probeer het opnieuw.
+ Stoppen
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 0f17b3d9..a6424735 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -317,4 +317,5 @@
Не удалось вычислить текущий адрес этого аксессуара.
Для воспроизведения звука поблизости требуется разрешение Bluetooth.
Не удалось воспроизвести звук. Попробуйте снова.
+ Стоп
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 9e9df214..887e615b 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -317,4 +317,5 @@
无法计算此配件当前的地址。
就近响铃需要蓝牙权限。
无法播放声音。请重试。
+ 停止
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index b54a366a..af22da67 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -317,4 +317,5 @@
無法計算此配件目前的位址。
就近響鈴需要藍牙權限。
無法播放聲音。請重試。
+ 停止
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4817b4f0..b8712eb7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -349,4 +349,5 @@ You can set this up now, or any time later from Settings.
Could not compute this accessory\'s current address.
Bluetooth permission is needed to play the sound nearby.
Could not play the sound. Try again.
+ Stop
From 23247223013f659590b1849eaa642ce7dc69d3c3 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sat, 22 Aug 2026 14:13:57 +0200
Subject: [PATCH 03/61] Turn playSound into a progress stream, and retry a
flaky GATT connection
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 (, 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.
---
.../opentagviewer/DeviceInfoActivity.java | 53 ++-
.../android/opentagviewer/MapsActivity.java | 80 ++++-
.../ble/AccessorySoundTrigger.java | 22 +-
.../ble/BleAccessorySoundTrigger.java | 166 ++++++++--
.../ble/BleGattSoundTrigger.java | 30 +-
.../ble/BleSoundTriggerPhase.java | 16 +
.../ble/BleSoundTriggerUpdate.java | 29 ++
.../opentagviewer/python/AppDependencies.java | 4 +-
.../opentagviewer/ui/maps/TagCardHelper.java | 32 ++
app/src/main/res/values-de/strings.xml | 5 +
app/src/main/res/values-en/strings.xml | 5 +
app/src/main/res/values-fr/strings.xml | 5 +
app/src/main/res/values-ja/strings.xml | 5 +
app/src/main/res/values-ko/strings.xml | 5 +
app/src/main/res/values-nl/strings.xml | 5 +
app/src/main/res/values-ru/strings.xml | 5 +
app/src/main/res/values-zh-rCN/strings.xml | 5 +
app/src/main/res/values-zh-rTW/strings.xml | 5 +
app/src/main/res/values/strings.xml | 5 +
.../ble/BleAccessorySoundTriggerTest.java | 308 ++++++++++++++++++
20 files changed, 734 insertions(+), 56 deletions(-)
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerPhase.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerUpdate.java
create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index 685ab4fb..8399b8e9 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -4,6 +4,7 @@
import static android.view.View.VISIBLE;
import static android.view.View.inflate;
import static android.widget.Toast.LENGTH_LONG;
+import static android.widget.Toast.LENGTH_SHORT;
import static dev.wander.android.opentagviewer.util.android.TextChangedWatcherFactory.justWatchOnChanged;
@@ -50,7 +51,9 @@
import java.util.Optional;
import dev.wander.android.opentagviewer.ble.BlePermissions;
+import dev.wander.android.opentagviewer.ble.BleSoundTriggerPhase;
import dev.wander.android.opentagviewer.ble.BleSoundTriggerResult;
+import dev.wander.android.opentagviewer.ble.BleSoundTriggerUpdate;
import dev.wander.android.opentagviewer.data.model.BeaconInformation;
import dev.wander.android.opentagviewer.data.model.UserMapCameraPosition;
import dev.wander.android.opentagviewer.databinding.ActivityDeviceInfoBinding;
@@ -133,6 +136,10 @@ public class DeviceInfoActivity extends AppCompatActivity
* leaving a scan running or a result landing on dead views. */
private Disposable playSoundNearby;
+ /** Reused so each new status (searching/connecting/sending/result) replaces the last one
+ * on screen instead of queuing behind it - see {@link #showPlaySoundStatus}. */
+ private Toast playSoundStatusToast;
+
private boolean hasNameChanges = false;
@Override
@@ -646,7 +653,7 @@ public void onRequestPermissionsResult(
private void startPlaySoundNearby() {
final String accessoryJson = this.beaconData.getOwnedBeaconInfo().accessoryJson;
- Toast.makeText(this, R.string.play_sound_searching, LENGTH_LONG).show();
+ this.showPlaySoundStatus(R.string.play_sound_searching, LENGTH_SHORT);
if (this.playSoundNearby != null && !this.playSoundNearby.isDisposed()) {
this.playSoundNearby.dispose();
@@ -656,17 +663,41 @@ private void startPlaySoundNearby() {
.playSound(this.getApplicationContext(), accessoryJson)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
- this::showPlaySoundResult,
+ this::handlePlaySoundUpdate,
error -> {
// AccessorySoundTrigger's contract is to never error a failure onto
// this path - see its interface doc - so reaching here means a bug
// in that contract, not an ordinary "not found" or "no permission".
Log.e(TAG, "Unexpected error playing sound for beaconId="
+ this.beaconId, error);
- Toast.makeText(this, R.string.play_sound_failed, LENGTH_LONG).show();
+ this.showPlaySoundStatus(R.string.play_sound_failed, LENGTH_LONG);
});
}
+ /**
+ * One item of the play-sound stream: a progress phase (shown and replaced, see
+ * {@link #showPlaySoundStatus}) or the terminal outcome.
+ */
+ private void handlePlaySoundUpdate(final BleSoundTriggerUpdate update) {
+ if (update.getPhase() != BleSoundTriggerPhase.DONE) {
+ this.showPlaySoundStatus(phaseMessageRes(update.getPhase()), LENGTH_SHORT);
+ return;
+ }
+ this.showPlaySoundResult(update.getResult());
+ }
+
+ private static int phaseMessageRes(final BleSoundTriggerPhase phase) {
+ switch (phase) {
+ case CONNECTING:
+ return R.string.play_sound_connecting;
+ case TRIGGERING:
+ return R.string.play_sound_sending;
+ case SCANNING:
+ default:
+ return R.string.play_sound_searching;
+ }
+ }
+
private void showPlaySoundResult(final BleSoundTriggerResult result) {
Log.d(TAG, "Play sound result for beaconId=" + this.beaconId + ": " + result.getStatus()
+ (result.getMessage() == null ? "" : " (" + result.getMessage() + ")"));
@@ -693,7 +724,21 @@ private void showPlaySoundResult(final BleSoundTriggerResult result) {
messageRes = R.string.play_sound_failed;
break;
}
- Toast.makeText(this, messageRes, LENGTH_LONG).show();
+ this.showPlaySoundStatus(messageRes, LENGTH_LONG);
+ }
+
+ /**
+ * Cancels whichever status toast is on screen and shows the next one immediately, rather
+ * than queuing behind it. Plain sequential {@code Toast.makeText(...).show()} calls queue
+ * with a fixed display duration each, so "searching" would sit on screen for its whole
+ * duration even after "connecting" was already true - reading as stuck, not as progress.
+ */
+ private void showPlaySoundStatus(final int messageRes, final int duration) {
+ if (this.playSoundStatusToast != null) {
+ this.playSoundStatusToast.cancel();
+ }
+ this.playSoundStatusToast = Toast.makeText(this, messageRes, duration);
+ this.playSoundStatusToast.show();
}
/**
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index 5b5242df..0eee1956 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -129,7 +129,9 @@
import dev.wander.android.opentagviewer.util.rx.RefreshPolicy;
import dev.wander.android.opentagviewer.util.rx.RxFlows;
import dev.wander.android.opentagviewer.ble.BlePermissions;
-import dev.wander.android.opentagviewer.ble.BleSoundTriggerResult;
+import dev.wander.android.opentagviewer.ble.BleSoundTriggerPhase;
+import dev.wander.android.opentagviewer.ble.BleSoundTriggerStatus;
+import dev.wander.android.opentagviewer.ble.BleSoundTriggerUpdate;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Observable;
@@ -1345,9 +1347,7 @@ private void startContinuousPing(final String beaconId) {
.playSoundContinuously(this.getApplicationContext(), accessoryJson)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
- (BleSoundTriggerResult result) -> Log.d(TAG, "Continuous ping attempt for beaconId="
- + beaconId + ": " + result.getStatus()
- + (result.getMessage() == null ? "" : " (" + result.getMessage() + ")")),
+ update -> this.handleContinuousPingUpdate(beaconId, update),
error -> {
// playSoundContinuously's contract is to never error onto this path -
// see its interface doc - so reaching here means a bug in that
@@ -1357,6 +1357,74 @@ private void startContinuousPing(final String beaconId) {
});
}
+ /**
+ * Shows continuous ping's current phase on the card's ring label - "Scanning...",
+ * "Connecting...", "Sending..." - so it reads as active work rather than nothing happening,
+ * without a toast firing every few seconds for as long as it runs.
+ *
+ * Between cycles ({@link BleSoundTriggerPhase#DONE}) the label goes back to "Stop" rather
+ * than showing that cycle's result: a failed or not-found attempt does not mean pinging has
+ * stopped, and showing it as if it had would read as broken.
+ * {@link BleSoundTriggerStatus#MISSING_PERMISSION} and
+ * {@link BleSoundTriggerStatus#NO_CANDIDATE_MACS} do not follow that rule: nothing about
+ * waiting and trying again fixes either, so looping on them is pure battery burn with no
+ * chance of succeeding - this stops the loop and says why instead.
+ */
+ private void handleContinuousPingUpdate(final String beaconId, final BleSoundTriggerUpdate update) {
+ Log.d(TAG, "Continuous ping update for beaconId=" + beaconId + ": " + update.getPhase()
+ + (update.getResult() == null ? "" : " (" + update.getResult().getStatus() + ")"));
+
+ // A card for a beaconId other than the one this loop is for stopped existing (e.g. the
+ // tag left the visible list) or continuous ping was stopped/switched to another tag
+ // since this update was emitted - either way, there is nothing left to show it on.
+ if (!beaconId.equals(this.continuousPingBeaconId)) {
+ return;
+ }
+
+ if (update.getPhase() == BleSoundTriggerPhase.DONE) {
+ final BleSoundTriggerStatus status = update.getResult().getStatus();
+ if (status == BleSoundTriggerStatus.MISSING_PERMISSION
+ || status == BleSoundTriggerStatus.NO_CANDIDATE_MACS) {
+ Log.w(TAG, "Stopping continuous ping for beaconId=" + beaconId
+ + ": unrecoverable status " + status);
+ this.stopContinuousPing();
+ Toast.makeText(this, status == BleSoundTriggerStatus.MISSING_PERMISSION
+ ? R.string.play_sound_permission_denied
+ : R.string.play_sound_no_candidate_macs,
+ LENGTH_LONG).show();
+ return;
+ }
+ }
+
+ final FrameLayout container = this.dynamicCardsForTag.get(beaconId);
+ if (container == null) {
+ return;
+ }
+
+ final int labelRes;
+ switch (update.getPhase()) {
+ case CONNECTING:
+ labelRes = R.string.ring_status_connecting;
+ break;
+ case TRIGGERING:
+ labelRes = R.string.ring_status_triggering;
+ break;
+ case DONE:
+ labelRes = R.string.stop_ringing;
+ break;
+ case SCANNING:
+ default:
+ labelRes = R.string.ring_status_scanning;
+ break;
+ }
+ TagCardHelper.setRingLabel(container, this.getString(labelRes));
+
+ // The spinner runs for SCANNING/CONNECTING/TRIGGERING and stops at DONE - a label
+ // alone ("Scanning...", "Connecting...") can sit on screen for several seconds with
+ // nothing else moving, which reads as stuck rather than as work in progress.
+ TagCardHelper.setRingLoading(container, update.getPhase() != BleSoundTriggerPhase.DONE);
+ }
+
private void stopContinuousPing() {
if (this.continuousPingDisposable != null && !this.continuousPingDisposable.isDisposed()) {
this.continuousPingDisposable.dispose();
@@ -1367,6 +1435,10 @@ private void stopContinuousPing() {
final FrameLayout container = this.dynamicCardsForTag.get(this.continuousPingBeaconId);
if (container != null) {
TagCardHelper.toggleRingActive(container, false);
+ // In case this stopped mid-attempt (spinner showing) rather than between
+ // cycles - otherwise the icon stays hidden behind a spinner that will never
+ // update again.
+ TagCardHelper.setRingLoading(container, false);
}
}
this.continuousPingBeaconId = null;
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java
index 51e618ad..c7446537 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java
@@ -3,7 +3,6 @@
import android.content.Context;
import io.reactivex.rxjava3.core.Observable;
-import io.reactivex.rxjava3.core.Single;
/**
* Plays an owned accessory's sound directly over Bluetooth, without going through Apple's Find
@@ -18,21 +17,24 @@ public interface AccessorySoundTrigger {
/**
* @param context used for the Bluetooth system service and permission checks.
* @param accessoryJson the persisted {@code OwnedBeacon.accessoryJson} for this beacon.
- * @return a {@link Single} emitting exactly one {@link BleSoundTriggerResult}. Never errors -
- * every failure this can hit (no permission, not in range, connect/write failure) is a
- * status on the result, not an exception, so a caller only ever needs {@code subscribe} with
- * one lambda.
+ * @return an {@link Observable} of {@link BleSoundTriggerUpdate}s - progress phases
+ * (scanning, connecting, triggering) followed by exactly one terminal
+ * {@link BleSoundTriggerPhase#DONE} carrying the {@link BleSoundTriggerResult}, then
+ * completes. Never errors - every failure this can hit (no permission, not in range,
+ * connect/write failure) is a status on the DONE result, not an exception, so a caller only
+ * ever needs {@code subscribe} with one lambda. The progress items exist so a caller can show
+ * "connecting..." instead of nothing for however long the handshake takes.
*/
- Single playSound(Context context, String accessoryJson);
+ Observable playSound(Context context, String accessoryJson);
/**
* Repeats {@link #playSound} - scan, trigger (or fail), pause, scan again - for as long as
* the returned {@link Observable} stays subscribed. For walking toward a tag by ear: a
* single {@link #playSound} only ever gets one chance to be in range at the moment it scans.
*
- * Never errors, same as {@link #playSound} - each attempt's outcome is an item, not a
- * terminal signal, so one failed attempt (e.g. briefly out of range) does not end the loop.
- * Dispose the subscription to stop.
+ *
Never errors, same as {@link #playSound} - each item is a progress phase or a DONE
+ * outcome, not a terminal signal, so one failed cycle (e.g. briefly out of range) does not
+ * end the loop. Dispose the subscription to stop.
*/
- Observable playSoundContinuously(Context context, String accessoryJson);
+ Observable playSoundContinuously(Context context, String accessoryJson);
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
index dede76d8..6d7f0a60 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
@@ -1,5 +1,6 @@
package dev.wander.android.opentagviewer.ble;
+import android.bluetooth.BluetoothDevice;
import android.content.Context;
import java.util.HashSet;
@@ -18,12 +19,44 @@
*
* What this has actually been run against, and what it has not. The GATT protocol
* logic in {@link BleGattSoundTrigger} is a port of a Kotlin prototype (a personal companion
- * project, TrackerHunter) that was exercised against real AirTags over BLE. This class - the
- * permission gate, the MAC resolution via the pinned FindMy.py fork, and wiring the scan result
- * into the trigger - has been read carefully but not run end-to-end on a device by whoever
- * wrote it. Per AGENTS.md rule 2: say so rather than claim otherwise.
+ * project, TrackerHunter) that was exercised against real AirTags over BLE. This class's own
+ * orchestration - the permission gate, the retry count, the continuous-repeat wiring - has a
+ * JVM test suite ({@code BleAccessorySoundTriggerTest}) exercising it against fakes, but has not
+ * been run end-to-end on a device by whoever wrote it. Per AGENTS.md rule 2: say so rather than
+ * claim otherwise.
+ *
+ *
Why the BLE pieces are constructor-injected rather than static calls to
+ * {@link NearbyAccessoryScanner}/{@link BleGattSoundTrigger}/{@link BlePermissions}. Those
+ * three need Android hardware to run for real, which a JVM test cannot arrange - same reasoning
+ * as {@code AppDependencies} injecting {@code HardwareDescriber} instead of calling Chaquopy
+ * directly. This class's own logic (which status is worth retrying, how many times, what a
+ * missing permission or an empty candidate set short-circuits to) is what the seams exist to
+ * test, without needing Bluetooth or a device to do it.
+ *
+ *
Why {@code } at all, rather than just {@code BluetoothDevice}. A found device is
+ * opaque to every line of logic in this class - it is looked at nowhere, only handed from the
+ * scanner seam to the GATT seam. Fixing it to {@code BluetoothDevice} would mean the test suite
+ * needs one, and the real SDK class has no public constructor and no test double in this project
+ * (no Robolectric here - see AGENTS.md's JVM-vs-instrumented split). A type parameter lets the
+ * test use a plain {@code String} as a stand-in and this class stays none the wiser; production
+ * code fixes {@code D} to {@code BluetoothDevice} once, in the public constructor's inferred type.
*/
-public class BleAccessorySoundTrigger implements AccessorySoundTrigger {
+public class BleAccessorySoundTrigger implements AccessorySoundTrigger {
+
+ /** Whether the required Bluetooth permission(s) are granted. Real: {@link BlePermissions#granted}. */
+ interface PermissionCheck {
+ boolean granted(Context context);
+ }
+
+ /** Scans for one of the candidate MACs. Real: {@link NearbyAccessoryScanner#findNearby}. */
+ interface Scanner {
+ Single findNearby(Context context, Set candidateMacs, long timeoutMs);
+ }
+
+ /** Runs the GATT handshake against a found device. Real: {@link BleGattSoundTrigger#trigger}. */
+ interface GattTrigger {
+ Observable trigger(Context context, D device);
+ }
/**
* How long to scan before giving up. Long enough that an AirTag's ~1 second-ish advertising
@@ -32,6 +65,22 @@ public class BleAccessorySoundTrigger implements AccessorySoundTrigger {
*/
private static final long SCAN_TIMEOUT_MS = 15_000L;
+ /**
+ * How many GATT attempts one found device gets before this counts as failed and the caller
+ * decides what to do next (for {@link #playSoundContinuously}, that means re-scanning).
+ *
+ * BLE connection setup is failure-prone in ways that mean nothing about the accessory
+ * itself - a stale radio state, a busy Bluetooth stack, a connection that timed out for no
+ * reason a retry wouldn't also hit. Only worth it for {@link BleSoundTriggerStatus#FAILED}:
+ * {@link BleSoundTriggerStatus#NO_SOUND_SERVICE} means the connection worked and nothing
+ * this app recognises was there, which retrying the same device will not change.
+ */
+ private static final int GATT_ATTEMPTS = 3;
+
+ /** Pause between attempts, so a retry isn't fired at a radio still settling from the
+ * previous attempt's disconnect. */
+ private static final long GATT_RETRY_DELAY_MS = 800L;
+
/**
* How long {@link #playSoundContinuously} waits after one attempt (found or not) before the
* next. Short enough to feel responsive while walking toward a tag; long enough that a
@@ -40,49 +89,112 @@ public class BleAccessorySoundTrigger implements AccessorySoundTrigger {
private static final long CONTINUOUS_PING_PAUSE_MS = 4_000L;
private final AccessoryMacResolver macResolver;
+ private final PermissionCheck permissionCheck;
+ private final Scanner scanner;
+ private final GattTrigger gattTrigger;
+ private final int gattAttempts;
+ private final long gattRetryDelayMs;
+ private final long continuousPingPauseMs;
+
+ /**
+ * The real thing: {@code D} fixed to {@link BluetoothDevice}, and every seam wired to its
+ * real Android implementation. A static factory rather than a public constructor because a
+ * plain constructor on a generic class cannot pin {@code D} for its caller - the seam method
+ * references here are concretely {@code BluetoothDevice}-typed, so the constructor itself
+ * has to be the one that says so.
+ */
+ public static BleAccessorySoundTrigger forRealBluetooth(
+ final AccessoryMacResolver macResolver) {
+ return new BleAccessorySoundTrigger<>(macResolver, BlePermissions::granted,
+ NearbyAccessoryScanner::findNearby, BleGattSoundTrigger::trigger,
+ GATT_ATTEMPTS, GATT_RETRY_DELAY_MS, CONTINUOUS_PING_PAUSE_MS);
+ }
- public BleAccessorySoundTrigger(final AccessoryMacResolver macResolver) {
+ /** Package-private: only {@code BleAccessorySoundTriggerTest} constructs one of these with fakes. */
+ BleAccessorySoundTrigger(
+ final AccessoryMacResolver macResolver,
+ final PermissionCheck permissionCheck,
+ final Scanner scanner,
+ final GattTrigger gattTrigger,
+ final int gattAttempts,
+ final long gattRetryDelayMs,
+ final long continuousPingPauseMs) {
this.macResolver = macResolver;
+ this.permissionCheck = permissionCheck;
+ this.scanner = scanner;
+ this.gattTrigger = gattTrigger;
+ this.gattAttempts = gattAttempts;
+ this.gattRetryDelayMs = gattRetryDelayMs;
+ this.continuousPingPauseMs = continuousPingPauseMs;
}
@Override
- public Single playSound(final Context context, final String accessoryJson) {
- return Single.defer(() -> {
- if (!BlePermissions.granted(context)) {
- return Single.just(new BleSoundTriggerResult(BleSoundTriggerStatus.MISSING_PERMISSION,
- null, "Bluetooth scan/connect permission not granted"));
+ public Observable playSound(final Context context, final String accessoryJson) {
+ return Observable.defer(() -> {
+ if (!this.permissionCheck.granted(context)) {
+ return Observable.just(BleSoundTriggerUpdate.done(new BleSoundTriggerResult(
+ BleSoundTriggerStatus.MISSING_PERMISSION, null,
+ "Bluetooth scan/connect permission not granted")));
}
// Blocking - starts a Python interpreter. Safe here because the whole chain is
// subscribed on Schedulers.io() below, same as PythonAppleService's calls.
- final List macs = macResolver.currentMacAddresses(accessoryJson);
+ final List macs = this.macResolver.currentMacAddresses(accessoryJson);
if (macs.isEmpty()) {
- return Single.just(new BleSoundTriggerResult(BleSoundTriggerStatus.NO_CANDIDATE_MACS,
- null, "Could not resolve a current MAC address for this accessory"));
+ return Observable.just(BleSoundTriggerUpdate.done(new BleSoundTriggerResult(
+ BleSoundTriggerStatus.NO_CANDIDATE_MACS, null,
+ "Could not resolve a current MAC address for this accessory")));
}
final Set candidates = new HashSet<>(macs);
- return NearbyAccessoryScanner.findNearby(context, candidates, SCAN_TIMEOUT_MS)
- .flatMap(device -> BleGattSoundTrigger.trigger(context, device))
- .onErrorReturn(BleAccessorySoundTrigger::asResult);
+ return Observable.just(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.SCANNING))
+ .concatWith(this.scanner.findNearby(context, candidates, SCAN_TIMEOUT_MS)
+ .toObservable()
+ .flatMap(device -> this.triggerWithRetry(context, device, this.gattAttempts))
+ .onErrorReturn(BleAccessorySoundTrigger::asDoneUpdate));
}).subscribeOn(Schedulers.io());
}
+ /**
+ * {@link GattTrigger#trigger}, retried up to {@code attemptsLeft} times as long as each
+ * failure is {@link BleSoundTriggerStatus#FAILED} - see {@link #GATT_ATTEMPTS}. Only the
+ * final attempt's DONE reaches the caller; earlier failed attempts are swallowed in favour
+ * of a fresh {@link BleSoundTriggerPhase#CONNECTING} and another try.
+ */
+ private Observable triggerWithRetry(
+ final Context context, final D device, final int attemptsLeft) {
+ return this.gattTrigger.trigger(context, device)
+ .concatMap(update -> {
+ final boolean isRetryableFailure = update.getPhase() == BleSoundTriggerPhase.DONE
+ && update.getResult().getStatus() == BleSoundTriggerStatus.FAILED;
+ if (!isRetryableFailure || attemptsLeft <= 1) {
+ return Observable.just(update);
+ }
+
+ return Observable.timer(this.gattRetryDelayMs, TimeUnit.MILLISECONDS)
+ .flatMap(tick -> Observable
+ .just(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.CONNECTING))
+ .concatWith(Observable.defer(() ->
+ this.triggerWithRetry(context, device, attemptsLeft - 1))));
+ });
+ }
+
@Override
- public Observable playSoundContinuously(
+ public Observable playSoundContinuously(
final Context context, final String accessoryJson) {
- // playSound is a Single, so it completes after its one item; repeatWhen re-subscribes
- // it once the delayed completion signal fires, which is what turns "do it once" into
- // "do it again after a pause", forever, until the subscriber disposes.
- return playSound(context, accessoryJson)
- .toObservable()
- .repeatWhen(completed -> completed.delay(CONTINUOUS_PING_PAUSE_MS, TimeUnit.MILLISECONDS));
+ // playSound completes after its DONE item; repeatWhen re-subscribes it once the delayed
+ // completion signal fires, which is what turns "do it once" into "do it again after a
+ // pause", forever, until the subscriber disposes.
+ return this.playSound(context, accessoryJson)
+ .repeatWhen(completed -> completed.delay(this.continuousPingPauseMs, TimeUnit.MILLISECONDS));
}
- private static BleSoundTriggerResult asResult(final Throwable error) {
+ private static BleSoundTriggerUpdate asDoneUpdate(final Throwable error) {
if (error instanceof NearbyAccessoryScanner.NotNearbyException) {
- return new BleSoundTriggerResult(BleSoundTriggerStatus.NOT_NEARBY, null, error.getMessage());
+ return BleSoundTriggerUpdate.done(new BleSoundTriggerResult(
+ BleSoundTriggerStatus.NOT_NEARBY, null, error.getMessage()));
}
- return new BleSoundTriggerResult(BleSoundTriggerStatus.FAILED, null, String.valueOf(error.getMessage()));
+ return BleSoundTriggerUpdate.done(new BleSoundTriggerResult(
+ BleSoundTriggerStatus.FAILED, null, String.valueOf(error.getMessage())));
}
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java
index d1586692..975fc046 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java
@@ -16,7 +16,7 @@
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
-import io.reactivex.rxjava3.core.Single;
+import io.reactivex.rxjava3.core.Observable;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@@ -31,7 +31,7 @@
*
* Ported from a Kotlin prototype (a personal companion project, TrackerHunter) that already
* exercised this against real AirTags; this is the same state machine expressed as a Java
- * {@link Single} instead of a coroutine, to match this app's RxJava3 convention. See
+ * {@link Observable} instead of a coroutine, to match this app's RxJava3 convention. See
* {@code BleAccessorySoundTrigger} for the honesty about what has and has not actually been run.
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@@ -62,13 +62,16 @@ public final class BleGattSoundTrigger {
* matching one's start command has been written (or all three failed). Does not wait for the
* sound to finish playing.
*
- *
Emits exactly once. Disposing the returned {@link Single} before it emits disconnects
- * and closes the GATT connection rather than leaving it open in the background.
+ *
Emits a {@link BleSoundTriggerPhase#CONNECTING} update immediately, a
+ * {@link BleSoundTriggerPhase#TRIGGERING} one once a matching sound service is found, then
+ * exactly one {@link BleSoundTriggerPhase#DONE} - so a caller can show "connecting..."
+ * instead of nothing for however long the handshake takes. Disposing before it completes
+ * disconnects and closes the GATT connection rather than leaving it open in the background.
*/
@SuppressLint("MissingPermission")
- public static Single trigger(
+ public static Observable trigger(
final Context context, final BluetoothDevice device) {
- return Single.create(emitter -> {
+ return Observable.create(emitter -> {
final AtomicBoolean resumed = new AtomicBoolean(false);
final BluetoothGatt[] gattRef = new BluetoothGatt[1];
@@ -79,10 +82,12 @@ public static Single trigger(
private void finish(final BleSoundTriggerResult result) {
// Guards against a callback landing twice (e.g. a disconnect that follows a
- // successful write) - only the first one reaches the emitter, matching
- // Single's exactly-once contract.
+ // successful write) - only the first one reaches the emitter, matching the
+ // "exactly one DONE, then complete" contract.
if (!resumed.compareAndSet(false, true)) return;
- if (!emitter.isDisposed()) emitter.onSuccess(result);
+ if (emitter.isDisposed()) return;
+ emitter.onNext(BleSoundTriggerUpdate.done(result));
+ emitter.onComplete();
}
@Override
@@ -116,6 +121,12 @@ public void onServicesDiscovered(final BluetoothGatt gatt, final int status) {
final BluetoothGattCharacteristic findMy = findMyCharacteristic(gatt);
final BluetoothGattCharacteristic airtag = characteristicOf(gatt, AIRTAG_SERVICE, AIRTAG_CHARACTERISTIC);
+ if (dult != null || findMy != null || airtag != null) {
+ if (!emitter.isDisposed()) {
+ emitter.onNext(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.TRIGGERING));
+ }
+ }
+
if (dult != null) {
enableNotifyThenWrite(gatt, dult, DULT_START_OPCODE, "DULT");
} else if (findMy != null) {
@@ -178,6 +189,7 @@ public void onCharacteristicWrite(
}
};
+ emitter.onNext(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.CONNECTING));
gattRef[0] = device.connectGatt(context, false, callback);
emitter.setCancellable(() -> {
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerPhase.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerPhase.java
new file mode 100644
index 00000000..c5fa2c8b
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerPhase.java
@@ -0,0 +1,16 @@
+package dev.wander.android.opentagviewer.ble;
+
+/** Where one {@link AccessorySoundTrigger#playSound} attempt currently is. */
+public enum BleSoundTriggerPhase {
+ /** Scanning for one of the accessory's candidate BLE addresses. */
+ SCANNING,
+
+ /** Found it; opening a GATT connection. */
+ CONNECTING,
+
+ /** Connected; writing the play-sound characteristic. */
+ TRIGGERING,
+
+ /** Finished - see the accompanying {@link BleSoundTriggerResult}. */
+ DONE,
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerUpdate.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerUpdate.java
new file mode 100644
index 00000000..ec47a0c5
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerUpdate.java
@@ -0,0 +1,29 @@
+package dev.wander.android.opentagviewer.ble;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * One item of an {@link AccessorySoundTrigger#playSound} stream: either a progress phase with no
+ * result yet, or the terminal {@link BleSoundTriggerPhase#DONE} carrying the outcome.
+ *
+ * A stream rather than a single terminal value so a caller can show "found, connecting..."
+ * instead of going silent for however long the GATT handshake takes - which otherwise reads as
+ * nothing happening, especially the first time someone uses this.
+ */
+@AllArgsConstructor
+@Getter
+public class BleSoundTriggerUpdate {
+ private final BleSoundTriggerPhase phase;
+
+ /** Non-null if and only if {@link #phase} is {@link BleSoundTriggerPhase#DONE}. */
+ private final BleSoundTriggerResult result;
+
+ public static BleSoundTriggerUpdate progress(final BleSoundTriggerPhase phase) {
+ return new BleSoundTriggerUpdate(phase, null);
+ }
+
+ public static BleSoundTriggerUpdate done(final BleSoundTriggerResult result) {
+ return new BleSoundTriggerUpdate(BleSoundTriggerPhase.DONE, result);
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java
index 3a181330..68dae68c 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java
@@ -93,7 +93,7 @@ public interface AnisetteFactory {
* one in a test replaces what the other depends on too.
*/
private static AccessorySoundTrigger accessorySoundTrigger =
- new BleAccessorySoundTrigger(accessoryMacResolver);
+ BleAccessorySoundTrigger.forRealBluetooth(accessoryMacResolver);
/**
* Strips personal identifiers out of a log before it is offered to anybody.
@@ -263,7 +263,7 @@ public static void reset() {
serverTesterFactory = AnisetteServerTesterService::new;
hardwareDescriber = new ChaquopyHardwareDescriber();
accessoryMacResolver = new ChaquopyAccessoryMacResolver();
- accessorySoundTrigger = new BleAccessorySoundTrigger(accessoryMacResolver);
+ accessorySoundTrigger = BleAccessorySoundTrigger.forRealBluetooth(accessoryMacResolver);
logRedactor = new ChaquopyLogRedactor();
bundleBuilder = new ChaquopyBundleBuilder();
icloudFactory = AppDependencies::openRealICloud;
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/TagCardHelper.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/TagCardHelper.java
index fab9a23e..2be3e106 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/TagCardHelper.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/TagCardHelper.java
@@ -59,6 +59,38 @@ public static void toggleRingActive(FrameLayout container, boolean active) {
}
}
+ /**
+ * Updates only the ring button's label text, leaving its icon/tint alone - for showing
+ * continuous ping's current phase (scanning/connecting/sending) between the on/off states
+ * {@link #toggleRingActive} sets.
+ */
+ public static void setRingLabel(FrameLayout container, CharSequence text) {
+ try {
+ TextView label = container.findViewById(R.id.ringText);
+ label.setText(text);
+ } catch (Exception e) {
+ Log.e(TAG, "Failure while trying to update the ring button's label", e);
+ }
+ }
+
+ /**
+ * Swaps the ring icon for a spinner while a scan/connect/trigger attempt is actually in
+ * flight - the label alone ("Scanning...", "Connecting...") was mistaken for a stall,
+ * since it can sit on screen for several seconds with nothing else moving. Independent of
+ * {@link #toggleRingActive}: this toggles per attempt, that toggles per on/off.
+ */
+ public static void setRingLoading(FrameLayout container, boolean loading) {
+ try {
+ ImageView icon = container.findViewById(R.id.perform_ring_icon);
+ CircularProgressIndicator progressIndicator = container.findViewById(R.id.ring_loading_indicator);
+
+ icon.setVisibility(loading ? GONE : VISIBLE);
+ progressIndicator.setVisibility(loading ? VISIBLE : GONE);
+ } catch (Exception e) {
+ Log.e(TAG, "Failure while trying to toggle the loading status on the ring button", e);
+ }
+ }
+
public static void toggleRefreshLoadingAll(Map containers, boolean isLoading) {
try {
for (var frameLayout : containers.values()) {
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 793b1272..6adf97e4 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -318,4 +318,9 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Für das Klingeln in der Nähe wird die Bluetooth-Berechtigung benötigt.
Ton konnte nicht abgespielt werden. Versuche es erneut.
Stopp
+ Gefunden, verbinde…
+ Sende Ton-Befehl…
+ Suche…
+ Verbinde…
+ Sende…
\ No newline at end of file
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index 29a6fc97..51d5af97 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -318,4 +318,9 @@ You can set this up now, or any time later from Settings.
Bluetooth permission is needed to play the sound nearby.
Could not play the sound. Try again.
Stop
+ Found nearby, connecting…
+ Sending sound command…
+ Scanning…
+ Connecting…
+ Sending…
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 1c55491a..b7d2fd7b 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -318,4 +318,9 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.<
L\'autorisation Bluetooth est nécessaire pour faire sonner l\'appareil à proximité.
Impossible de jouer le son. Réessayez.
Arrêter
+ Trouvé à proximité, connexion…
+ Envoi de la commande sonore…
+ Recherche…
+ Connexion…
+ Envoi…
\ No newline at end of file
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 1e5357fc..3b2351b4 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -318,4 +318,9 @@
近くで音を鳴らすにはBluetoothの権限が必要です。
音を再生できませんでした。もう一度お試しください。
停止
+ 近くで見つかりました、接続中…
+ 音声コマンドを送信中…
+ 検索中…
+ 接続中…
+ 送信中…
\ No newline at end of file
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index f5cf2092..6682c07a 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -318,4 +318,9 @@
근처에서 소리를 재생하려면 블루투스 권한이 필요합니다.
소리를 재생할 수 없습니다. 다시 시도하세요.
중지
+ 근처에서 찾았습니다, 연결 중…
+ 소리 명령 전송 중…
+ 검색 중…
+ 연결 중…
+ 전송 중…
\ No newline at end of file
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 6666f1ef..77cd74ff 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -318,4 +318,9 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen.
Bluetooth-toestemming is nodig om het geluid in de buurt af te spelen.
Kan het geluid niet afspelen. Probeer het opnieuw.
Stoppen
+ Gevonden, verbinden…
+ Geluidscommando versturen…
+ Zoeken…
+ Verbinden…
+ Versturen…
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index a6424735..1a13518f 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -318,4 +318,9 @@
Для воспроизведения звука поблизости требуется разрешение Bluetooth.
Не удалось воспроизвести звук. Попробуйте снова.
Стоп
+ Найдено поблизости, подключение…
+ Отправка команды звука…
+ Поиск…
+ Подключение…
+ Отправка…
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 887e615b..a96e1536 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -318,4 +318,9 @@
就近响铃需要蓝牙权限。
无法播放声音。请重试。
停止
+ 已找到,正在连接…
+ 正在发送声音指令…
+ 搜索中…
+ 连接中…
+ 发送中…
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index af22da67..2ee66147 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -318,4 +318,9 @@
就近響鈴需要藍牙權限。
無法播放聲音。請重試。
停止
+ 已找到,正在連線…
+ 正在傳送聲音指令…
+ 搜尋中…
+ 連線中…
+ 傳送中…
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b8712eb7..e11619bf 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -350,4 +350,9 @@ You can set this up now, or any time later from Settings.
Bluetooth permission is needed to play the sound nearby.
Could not play the sound. Try again.
Stop
+ Found nearby, connecting…
+ Sending sound command…
+ Scanning…
+ Connecting…
+ Sending…
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java
new file mode 100644
index 00000000..e2a93c8b
--- /dev/null
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java
@@ -0,0 +1,308 @@
+package dev.wander.android.opentagviewer.ble;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import org.junit.Test;
+
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import dev.wander.android.opentagviewer.python.AccessoryMacResolver;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.core.Single;
+import io.reactivex.rxjava3.disposables.Disposable;
+import io.reactivex.rxjava3.observers.TestObserver;
+
+/**
+ * Exercises {@link BleAccessorySoundTrigger}'s own orchestration - the permission gate, the
+ * retry count, the continuous-repeat wiring - against fakes for the three seams that would
+ * otherwise need real Bluetooth hardware ({@link BleAccessorySoundTrigger.PermissionCheck},
+ * {@link BleAccessorySoundTrigger.Scanner}, {@link BleAccessorySoundTrigger.GattTrigger}).
+ *
+ * Uses {@code String} as the fake "found device" type - see the class doc on why {@code }
+ * exists at all. A JVM test on purpose: nothing here needs Android or a device.
+ */
+public class BleAccessorySoundTriggerTest {
+
+ private static final String A_MAC = "AA:BB:CC:DD:EE:FF";
+ private static final String A_DEVICE = "fake-device";
+ private static final long AWAIT_SECONDS = 5;
+
+ private static AccessoryMacResolver resolverReturning(final List macs) {
+ return accessoryJson -> macs;
+ }
+
+ private static BleSoundTriggerUpdate doneUpdate(final BleSoundTriggerStatus status) {
+ return BleSoundTriggerUpdate.done(new BleSoundTriggerResult(status, null, "test"));
+ }
+
+ // --- permission gate --------------------------------------------------------------------
+
+ @Test
+ public void missingPermissionShortCircuitsBeforeResolvingAnyMac() throws InterruptedException {
+ final AtomicInteger resolverCalls = new AtomicInteger();
+ final AccessoryMacResolver resolver = accessoryJson -> {
+ resolverCalls.incrementAndGet();
+ return List.of(A_MAC);
+ };
+
+ final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
+ resolver,
+ context -> false,
+ unreachableScanner(),
+ unreachableGattTrigger(),
+ 3, 0L, 0L);
+
+ final List items = playSoundBlocking(trigger);
+
+ assertEquals(1, items.size());
+ assertEquals(BleSoundTriggerStatus.MISSING_PERMISSION, items.get(0).getResult().getStatus());
+ assertEquals("a denied permission must not even ask for a MAC address",
+ 0, resolverCalls.get());
+ }
+
+ // --- MAC resolution -----------------------------------------------------------------------
+
+ @Test
+ public void noCandidateMacsShortCircuitsBeforeScanning() throws InterruptedException {
+ final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
+ resolverReturning(List.of()),
+ context -> true,
+ unreachableScanner(),
+ unreachableGattTrigger(),
+ 3, 0L, 0L);
+
+ final List items = playSoundBlocking(trigger);
+
+ assertEquals(1, items.size());
+ assertEquals(BleSoundTriggerStatus.NO_CANDIDATE_MACS, items.get(0).getResult().getStatus());
+ }
+
+ // --- the happy path ---------------------------------------------------------------------
+
+ @Test
+ public void aSuccessfulRunEmitsScanningThenWhateverTheGattTriggerEmits() throws InterruptedException {
+ final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
+ resolverReturning(List.of(A_MAC)),
+ context -> true,
+ (context, macs, timeout) -> Single.just(A_DEVICE),
+ (context, device) -> Observable.just(
+ BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.CONNECTING),
+ BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.TRIGGERING),
+ doneUpdate(BleSoundTriggerStatus.SUCCESS)),
+ 3, 0L, 0L);
+
+ final List items = playSoundBlocking(trigger);
+
+ assertEquals(4, items.size());
+ assertEquals(BleSoundTriggerPhase.SCANNING, items.get(0).getPhase());
+ assertEquals(BleSoundTriggerPhase.CONNECTING, items.get(1).getPhase());
+ assertEquals(BleSoundTriggerPhase.TRIGGERING, items.get(2).getPhase());
+ assertEquals(BleSoundTriggerStatus.SUCCESS, items.get(3).getResult().getStatus());
+ }
+
+ @Test
+ public void theCandidateMacsPassedToTheScannerComeFromTheResolver() throws InterruptedException {
+ final AtomicInteger seenCandidateCount = new AtomicInteger(-1);
+
+ final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
+ resolverReturning(List.of(A_MAC, "11:22:33:44:55:66")),
+ context -> true,
+ (context, macs, timeout) -> {
+ seenCandidateCount.set(macs.size());
+ return Single.just(A_DEVICE);
+ },
+ (context, device) -> Observable.just(doneUpdate(BleSoundTriggerStatus.SUCCESS)),
+ 3, 0L, 0L);
+
+ playSoundBlocking(trigger);
+
+ assertEquals(2, seenCandidateCount.get());
+ }
+
+ // --- retry ------------------------------------------------------------------------------
+
+ @Test
+ public void aFailedAttemptIsRetriedUpToTheAttemptLimit() throws InterruptedException {
+ final AtomicInteger gattCalls = new AtomicInteger();
+
+ final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
+ resolverReturning(List.of(A_MAC)),
+ context -> true,
+ (context, macs, timeout) -> Single.just(A_DEVICE),
+ (context, device) -> {
+ gattCalls.incrementAndGet();
+ return Observable.just(doneUpdate(BleSoundTriggerStatus.FAILED));
+ },
+ 3, 0L, 0L);
+
+ final List items = playSoundBlocking(trigger);
+
+ assertEquals("FAILED should be retried until the attempt limit", 3, gattCalls.get());
+ assertEquals(BleSoundTriggerStatus.FAILED,
+ items.get(items.size() - 1).getResult().getStatus());
+ }
+
+ @Test
+ public void aSuccessfulRetryStopsFurtherAttempts() throws InterruptedException {
+ final AtomicInteger gattCalls = new AtomicInteger();
+
+ final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
+ resolverReturning(List.of(A_MAC)),
+ context -> true,
+ (context, macs, timeout) -> Single.just(A_DEVICE),
+ (context, device) -> Observable.just(gattCalls.incrementAndGet() == 1
+ ? doneUpdate(BleSoundTriggerStatus.FAILED)
+ : doneUpdate(BleSoundTriggerStatus.SUCCESS)),
+ 3, 0L, 0L);
+
+ final List items = playSoundBlocking(trigger);
+
+ assertEquals("should have stopped after the second, successful attempt", 2, gattCalls.get());
+ assertEquals(BleSoundTriggerStatus.SUCCESS,
+ items.get(items.size() - 1).getResult().getStatus());
+ }
+
+ @Test
+ public void noSoundServiceIsNeverRetried() throws InterruptedException {
+ final AtomicInteger gattCalls = new AtomicInteger();
+
+ final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
+ resolverReturning(List.of(A_MAC)),
+ context -> true,
+ (context, macs, timeout) -> Single.just(A_DEVICE),
+ (context, device) -> {
+ gattCalls.incrementAndGet();
+ return Observable.just(doneUpdate(BleSoundTriggerStatus.NO_SOUND_SERVICE));
+ },
+ 3, 0L, 0L);
+
+ final List items = playSoundBlocking(trigger);
+
+ assertEquals("connecting worked and found nothing recognisable - a retry cannot fix that",
+ 1, gattCalls.get());
+ assertEquals(BleSoundTriggerStatus.NO_SOUND_SERVICE,
+ items.get(items.size() - 1).getResult().getStatus());
+ }
+
+ // --- scanner failure modes ----------------------------------------------------------------
+
+ @Test
+ public void aScannerTimeoutMapsToNotNearby() throws InterruptedException {
+ final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
+ resolverReturning(List.of(A_MAC)),
+ context -> true,
+ (context, macs, timeout) -> Single.error(new NearbyAccessoryScanner.NotNearbyException()),
+ unreachableGattTrigger(),
+ 3, 0L, 0L);
+
+ final List items = playSoundBlocking(trigger);
+
+ assertEquals(BleSoundTriggerStatus.NOT_NEARBY,
+ items.get(items.size() - 1).getResult().getStatus());
+ }
+
+ @Test
+ public void anUnexpectedScannerErrorMapsToFailedRatherThanCrashing() throws InterruptedException {
+ final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
+ resolverReturning(List.of(A_MAC)),
+ context -> true,
+ (context, macs, timeout) -> Single.error(new IllegalStateException("radio is off")),
+ unreachableGattTrigger(),
+ 3, 0L, 0L);
+
+ final TestObserver observer = trigger.playSound(null, "{}").test();
+ assertTrue(observer.await(AWAIT_SECONDS, TimeUnit.SECONDS));
+ observer.assertComplete(); // the never-errors contract - see AccessorySoundTrigger's doc
+ observer.assertNoErrors();
+
+ final List items = observer.values();
+ assertEquals(BleSoundTriggerStatus.FAILED,
+ items.get(items.size() - 1).getResult().getStatus());
+ }
+
+ // --- continuous ping ----------------------------------------------------------------------
+
+ @Test
+ public void continuousPingRepeatsAfterEachCycleUntilDisposed() throws InterruptedException {
+ final AtomicInteger scannerCalls = new AtomicInteger();
+ final CountDownLatch sawThreeCycles = new CountDownLatch(1);
+
+ final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
+ resolverReturning(List.of(A_MAC)),
+ context -> true,
+ (context, macs, timeout) -> {
+ if (scannerCalls.incrementAndGet() >= 3) {
+ sawThreeCycles.countDown();
+ }
+ return Single.error(new NearbyAccessoryScanner.NotNearbyException());
+ },
+ unreachableGattTrigger(),
+ 3, 0L, 1L); // 1ms pause - fast, but still an async repeatWhen delay
+
+ final Disposable subscription = trigger.playSoundContinuously(null, "{}")
+ .subscribe(update -> { }, error -> fail("playSoundContinuously must never error"));
+ try {
+ assertTrue("expected at least 3 scan cycles within " + AWAIT_SECONDS + "s, got "
+ + scannerCalls.get(),
+ sawThreeCycles.await(AWAIT_SECONDS, TimeUnit.SECONDS));
+ } finally {
+ subscription.dispose();
+ }
+ }
+
+ @Test
+ public void disposingContinuousPingStopsFurtherCycles() throws InterruptedException {
+ final AtomicInteger scannerCalls = new AtomicInteger();
+
+ final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
+ resolverReturning(List.of(A_MAC)),
+ context -> true,
+ (context, macs, timeout) -> {
+ scannerCalls.incrementAndGet();
+ return Single.error(new NearbyAccessoryScanner.NotNearbyException());
+ },
+ unreachableGattTrigger(),
+ 3, 0L, 1L);
+
+ final Disposable subscription = trigger.playSoundContinuously(null, "{}")
+ .subscribe(update -> { }, error -> fail("playSoundContinuously must never error"));
+
+ // Give it a moment to run a few cycles, then stop it.
+ Thread.sleep(200);
+ subscription.dispose();
+ final int callsAtDispose = scannerCalls.get();
+ Thread.sleep(200);
+
+ assertEquals("a cycle ran after dispose - the loop was not actually stopped",
+ callsAtDispose, scannerCalls.get());
+ }
+
+ // --- helpers ------------------------------------------------------------------------------
+
+ private static List playSoundBlocking(
+ final BleAccessorySoundTrigger trigger) throws InterruptedException {
+ final TestObserver observer = trigger.playSound(null, "{}").test();
+ assertTrue("playSound did not complete within " + AWAIT_SECONDS + "s",
+ observer.await(AWAIT_SECONDS, TimeUnit.SECONDS));
+ observer.assertComplete();
+ observer.assertNoErrors();
+ return observer.values();
+ }
+
+ private static BleAccessorySoundTrigger.Scanner unreachableScanner() {
+ return (context, macs, timeout) -> {
+ throw new AssertionError("scanner should not have been called");
+ };
+ }
+
+ private static BleAccessorySoundTrigger.GattTrigger unreachableGattTrigger() {
+ return (context, device) -> {
+ throw new AssertionError("gatt trigger should not have been called");
+ };
+ }
+}
From c594cc472d2ef5a68b4223761fcc1ebc0ec9ba1e Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sat, 22 Aug 2026 14:16:50 +0200
Subject: [PATCH 04/61] Show "Ringing!" on a successful continuous-ping cycle,
not just "Stop"
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.
---
.../android/opentagviewer/MapsActivity.java | 21 ++++++++++++-------
app/src/main/res/values-de/strings.xml | 1 +
app/src/main/res/values-en/strings.xml | 1 +
app/src/main/res/values-fr/strings.xml | 1 +
app/src/main/res/values-ja/strings.xml | 1 +
app/src/main/res/values-ko/strings.xml | 1 +
app/src/main/res/values-nl/strings.xml | 1 +
app/src/main/res/values-ru/strings.xml | 1 +
app/src/main/res/values-zh-rCN/strings.xml | 1 +
app/src/main/res/values-zh-rTW/strings.xml | 1 +
app/src/main/res/values/strings.xml | 1 +
11 files changed, 23 insertions(+), 8 deletions(-)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index 0eee1956..407474ff 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -1362,13 +1362,16 @@ private void startContinuousPing(final String beaconId) {
* "Connecting...", "Sending..." - so it reads as active work rather than nothing happening,
* without a toast firing every few seconds for as long as it runs.
*
- * Between cycles ({@link BleSoundTriggerPhase#DONE}) the label goes back to "Stop" rather
- * than showing that cycle's result: a failed or not-found attempt does not mean pinging has
- * stopped, and showing it as if it had would read as broken.
- * {@link BleSoundTriggerStatus#MISSING_PERMISSION} and
- * {@link BleSoundTriggerStatus#NO_CANDIDATE_MACS} do not follow that rule: nothing about
- * waiting and trying again fixes either, so looping on them is pure battery burn with no
- * chance of succeeding - this stops the loop and says why instead.
+ *
Between cycles ({@link BleSoundTriggerPhase#DONE}) a successful write shows "Ringing!"
+ * rather than jumping straight back to "Stop" - the write itself is near-instant, so without
+ * this the sequence reads as scan, connect, done, with no visible moment where it actually
+ * worked. It shows for the whole {@code CONTINUOUS_PING_PAUSE_MS} gap before the next cycle's
+ * scan starts, which is also roughly how long an AirTag's chirp lasts. A not-found or failed
+ * attempt goes back to "Stop" directly and keeps looping - the tag may come into range on
+ * the next cycle. {@link BleSoundTriggerStatus#MISSING_PERMISSION} and
+ * {@link BleSoundTriggerStatus#NO_CANDIDATE_MACS} do not: nothing about waiting and trying
+ * again fixes either, so looping on them is pure battery burn with no chance of succeeding -
+ * this stops the loop and says why instead.
*/
private void handleContinuousPingUpdate(final String beaconId, final BleSoundTriggerUpdate update) {
Log.d(TAG, "Continuous ping update for beaconId=" + beaconId + ": " + update.getPhase()
@@ -1410,7 +1413,9 @@ private void handleContinuousPingUpdate(final String beaconId, final BleSoundTri
labelRes = R.string.ring_status_triggering;
break;
case DONE:
- labelRes = R.string.stop_ringing;
+ labelRes = update.getResult().getStatus() == BleSoundTriggerStatus.SUCCESS
+ ? R.string.ring_status_success
+ : R.string.stop_ringing;
break;
case SCANNING:
default:
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 6adf97e4..1d5aea9f 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -323,4 +323,5 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Suche…
Verbinde…
Sende…
+ Klingelt!
\ No newline at end of file
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index 51d5af97..cfdaee5e 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -323,4 +323,5 @@ You can set this up now, or any time later from Settings.
Scanning…
Connecting…
Sending…
+ Ringing!
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index b7d2fd7b..331c39a4 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -323,4 +323,5 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.<
Recherche…
Connexion…
Envoi…
+ Sonne !
\ No newline at end of file
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 3b2351b4..6d8af361 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -323,4 +323,5 @@
検索中…
接続中…
送信中…
+ 鳴っています!
\ No newline at end of file
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 6682c07a..3825f4fd 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -323,4 +323,5 @@
검색 중…
연결 중…
전송 중…
+ 울리는 중!
\ No newline at end of file
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 77cd74ff..a02f6a05 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -323,4 +323,5 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen.
Zoeken…
Verbinden…
Versturen…
+ Rinkelt!
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 1a13518f..a83a32d2 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -323,4 +323,5 @@
Поиск…
Подключение…
Отправка…
+ Звонит!
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index a96e1536..03aa7521 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -323,4 +323,5 @@
搜索中…
连接中…
发送中…
+ 响铃中!
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 2ee66147..e5c85d3b 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -323,4 +323,5 @@
搜尋中…
連線中…
傳送中…
+ 響鈴中!
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e11619bf..13f1b92a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -355,4 +355,5 @@ You can set this up now, or any time later from Settings.
Scanning…
Connecting…
Sending…
+ Ringing!
From 1d7e37bc01dba7f5f4b49ee81b193a6a9a3bb6b4 Mon Sep 17 00:00:00 2001
From: Shane B
Date: Sun, 23 Aug 2026 13:10:50 +0200
Subject: [PATCH 05/61] Pin the merged FindMy.py, and use the key index it now
hands back
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 #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
---
AGENTS.md | 12 ++
.../opentagviewer/DeviceInfoActivity.java | 22 +++
.../android/opentagviewer/MapsActivity.java | 53 ++++++-
.../ble/BleAccessorySoundTrigger.java | 49 ++++--
.../ble/BleSoundTriggerResult.java | 28 ++++
.../ble/BleSoundTriggerUpdate.java | 16 ++
.../db/repo/BeaconRepository.java | 41 ++++++
.../python/AccessoryMacResolver.java | 33 ++++-
.../opentagviewer/python/AppDependencies.java | 19 ++-
.../python/ChaquopyAccessoryMacResolver.java | 42 ++++--
app/src/main/python/main.py | 59 +++++++-
.../ble/BleAccessorySoundTriggerTest.java | 139 +++++++++++++++++-
12 files changed, 463 insertions(+), 50 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index cd026ad0..3c95a091 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,6 +21,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
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index 8399b8e9..838028b5 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -679,6 +679,8 @@ private void startPlaySoundNearby() {
* {@link #showPlaySoundStatus}) or the terminal outcome.
*/
private void handlePlaySoundUpdate(final BleSoundTriggerUpdate update) {
+ this.keepWhatTheSightingProved(update);
+
if (update.getPhase() != BleSoundTriggerPhase.DONE) {
this.showPlaySoundStatus(phaseMessageRes(update.getPhase()), LENGTH_SHORT);
return;
@@ -686,6 +688,26 @@ private void handlePlaySoundUpdate(final BleSoundTriggerUpdate update) {
this.showPlaySoundResult(update.getResult());
}
+ /**
+ * Keep the alignment a Bluetooth sighting proves, so the next scan is cheap.
+ *
+ * Fire and forget: this runs after the sound has already played or failed, and the user
+ * asked for a noise rather than for a database write. See
+ * {@code BeaconRepository#recordAccessorySighting}.
+ */
+ private void keepWhatTheSightingProved(final BleSoundTriggerUpdate update) {
+ if (update.getPhase() != BleSoundTriggerPhase.DONE
+ || update.getResult().getMatchedKeyIndex() == null) {
+ return;
+ }
+ this.beaconRepo.recordAccessorySighting(
+ this.beaconId,
+ update.getResult().getMatchedKeyIndex(),
+ System.currentTimeMillis())
+ .subscribe(() -> { }, error ->
+ Log.d(TAG, "Could not keep the alignment from a sighting", error));
+ }
+
private static int phaseMessageRes(final BleSoundTriggerPhase phase) {
switch (phase) {
case CONNECTING:
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index 407474ff..eba557b7 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -577,16 +577,34 @@ protected void onResume() {
}
}
+ /**
+ * Continuous ping stops when the screen does, not when the activity is destroyed.
+ *
+ *
Pressing Home does not destroy an activity, so disposing in {@code onDestroy} left the
+ * loop scanning and connecting over Bluetooth with the app in the background - burning the
+ * radio for a sound the user is no longer in a position to hear, and with no way to stop it
+ * short of coming back to this screen. {@code onDestroy} may not run for a long time, or at
+ * all before the process is killed.
+ *
+ *
{@code onStop} rather than {@code onPause}, which is a different question: pause
+ * fires for a dialog or the notification shade, and someone walking towards a tag by ear
+ * should not lose the ping to a passing notification. Stop means the screen is genuinely
+ * gone.
+ *
+ *
Through {@link #stopContinuousPing()} rather than disposing directly, so the card's
+ * button and spinner are reset too - otherwise returning to a stopped loop finds a card
+ * still captioned "Stop" with a spinner that will never move again.
+ */
+ @Override
+ protected void onStop() {
+ super.onStop();
+ this.stopContinuousPing();
+ }
+
@Override
protected void onDestroy() {
super.onDestroy();
- // Otherwise continuous ping keeps scanning/connecting in the background with no card
- // left to show it is running, or a way to stop it short of force-closing the app.
- if (this.continuousPingDisposable != null && !this.continuousPingDisposable.isDisposed()) {
- this.continuousPingDisposable.dispose();
- }
-
// 调用高德地图的生命周期方法
if (this.mapProvider instanceof AMapProvider) {
((AMapProvider) this.mapProvider).onDestroy();
@@ -1377,6 +1395,8 @@ private void handleContinuousPingUpdate(final String beaconId, final BleSoundTri
Log.d(TAG, "Continuous ping update for beaconId=" + beaconId + ": " + update.getPhase()
+ (update.getResult() == null ? "" : " (" + update.getResult().getStatus() + ")"));
+ this.keepWhatTheSightingProved(beaconId, update);
+
// A card for a beaconId other than the one this loop is for stopped existing (e.g. the
// tag left the visible list) or continuous ping was stopped/switched to another tag
// since this update was emitted - either way, there is nothing left to show it on.
@@ -1430,6 +1450,27 @@ private void handleContinuousPingUpdate(final String beaconId, final BleSoundTri
TagCardHelper.setRingLoading(container, update.getPhase() != BleSoundTriggerPhase.DONE);
}
+ /**
+ * Keep the alignment a Bluetooth sighting proves, so the next cycle's scan is cheap.
+ *
+ *
Continuous ping rescans every few seconds, so this is the difference between deriving a
+ * twelve-hour range over and over and deriving three keys. Fire and forget - see
+ * {@code BeaconRepository#recordAccessorySighting}.
+ */
+ private void keepWhatTheSightingProved(
+ final String beaconId, final BleSoundTriggerUpdate update) {
+ if (update.getPhase() != BleSoundTriggerPhase.DONE
+ || update.getResult().getMatchedKeyIndex() == null) {
+ return;
+ }
+ this.beaconRepo.recordAccessorySighting(
+ beaconId,
+ update.getResult().getMatchedKeyIndex(),
+ System.currentTimeMillis())
+ .subscribe(() -> { }, error ->
+ Log.d(TAG, "Could not keep the alignment from a sighting", error));
+ }
+
private void stopContinuousPing() {
if (this.continuousPingDisposable != null && !this.continuousPingDisposable.isDisposed()) {
this.continuousPingDisposable.dispose();
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
index 6d7f0a60..4bc7faa4 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
@@ -3,8 +3,7 @@
import android.bluetooth.BluetoothDevice;
import android.content.Context;
-import java.util.HashSet;
-import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@@ -58,6 +57,17 @@ interface GattTrigger {
Observable trigger(Context context, D device);
}
+ /**
+ * Reads the BLE address off a found device. Real: {@code BluetoothDevice::getAddress}.
+ *
+ * A seam for the same reason {@code D} is a type parameter at all: this class never looks
+ * at a device except to say which candidate it was, and requiring a real
+ * {@code BluetoothDevice} to answer that would put the whole class back on a device.
+ */
+ interface AddressOf {
+ String address(D device);
+ }
+
/**
* How long to scan before giving up. Long enough that an AirTag's ~1 second-ish advertising
* interval is seen several times over, short enough that tapping the button and walking away
@@ -89,6 +99,8 @@ interface GattTrigger {
private static final long CONTINUOUS_PING_PAUSE_MS = 4_000L;
private final AccessoryMacResolver macResolver;
+
+ private final AddressOf addressOf;
private final PermissionCheck permissionCheck;
private final Scanner scanner;
private final GattTrigger gattTrigger;
@@ -107,6 +119,7 @@ public static BleAccessorySoundTrigger forRealBluetooth(
final AccessoryMacResolver macResolver) {
return new BleAccessorySoundTrigger<>(macResolver, BlePermissions::granted,
NearbyAccessoryScanner::findNearby, BleGattSoundTrigger::trigger,
+ BluetoothDevice::getAddress,
GATT_ATTEMPTS, GATT_RETRY_DELAY_MS, CONTINUOUS_PING_PAUSE_MS);
}
@@ -116,6 +129,7 @@ public static BleAccessorySoundTrigger forRealBluetooth(
final PermissionCheck permissionCheck,
final Scanner scanner,
final GattTrigger gattTrigger,
+ final AddressOf addressOf,
final int gattAttempts,
final long gattRetryDelayMs,
final long continuousPingPauseMs) {
@@ -123,6 +137,7 @@ public static BleAccessorySoundTrigger forRealBluetooth(
this.permissionCheck = permissionCheck;
this.scanner = scanner;
this.gattTrigger = gattTrigger;
+ this.addressOf = addressOf;
this.gattAttempts = gattAttempts;
this.gattRetryDelayMs = gattRetryDelayMs;
this.continuousPingPauseMs = continuousPingPauseMs;
@@ -134,23 +149,35 @@ public Observable playSound(final Context context, final
if (!this.permissionCheck.granted(context)) {
return Observable.just(BleSoundTriggerUpdate.done(new BleSoundTriggerResult(
BleSoundTriggerStatus.MISSING_PERMISSION, null,
- "Bluetooth scan/connect permission not granted")));
+ "Bluetooth scan/connect permission not granted", null)));
}
// Blocking - starts a Python interpreter. Safe here because the whole chain is
// subscribed on Schedulers.io() below, same as PythonAppleService's calls.
- final List macs = this.macResolver.currentMacAddresses(accessoryJson);
- if (macs.isEmpty()) {
+ // Resolved once per attempt, not once per advertisement: the derivation and the
+ // trip across the Chaquopy bridge are the expensive parts, and the candidate set
+ // only moves when the fifteen-minute key interval ticks.
+ final Map candidates =
+ this.macResolver.currentMacAddresses(accessoryJson);
+ if (candidates.isEmpty()) {
return Observable.just(BleSoundTriggerUpdate.done(new BleSoundTriggerResult(
BleSoundTriggerStatus.NO_CANDIDATE_MACS, null,
- "Could not resolve a current MAC address for this accessory")));
+ "Could not resolve a current MAC address for this accessory", null)));
}
- final Set candidates = new HashSet<>(macs);
return Observable.just(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.SCANNING))
- .concatWith(this.scanner.findNearby(context, candidates, SCAN_TIMEOUT_MS)
+ .concatWith(this.scanner
+ .findNearby(context, candidates.keySet(), SCAN_TIMEOUT_MS)
.toObservable()
- .flatMap(device -> this.triggerWithRetry(context, device, this.gattAttempts))
+ .flatMap(device -> {
+ // Which of the candidates answered - the index behind it is what
+ // lets the caller pin the alignment and keep the next scan cheap.
+ final Integer matched =
+ candidates.get(this.addressOf.address(device));
+
+ return this.triggerWithRetry(context, device, this.gattAttempts)
+ .map(update -> update.withMatchedKeyIndex(matched));
+ })
.onErrorReturn(BleAccessorySoundTrigger::asDoneUpdate));
}).subscribeOn(Schedulers.io());
}
@@ -192,9 +219,9 @@ public Observable playSoundContinuously(
private static BleSoundTriggerUpdate asDoneUpdate(final Throwable error) {
if (error instanceof NearbyAccessoryScanner.NotNearbyException) {
return BleSoundTriggerUpdate.done(new BleSoundTriggerResult(
- BleSoundTriggerStatus.NOT_NEARBY, null, error.getMessage()));
+ BleSoundTriggerStatus.NOT_NEARBY, null, error.getMessage(), null));
}
return BleSoundTriggerUpdate.done(new BleSoundTriggerResult(
- BleSoundTriggerStatus.FAILED, null, String.valueOf(error.getMessage())));
+ BleSoundTriggerStatus.FAILED, null, String.valueOf(error.getMessage()), null));
}
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java
index c4a33d3e..637c0fcb 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java
@@ -14,4 +14,32 @@ public class BleSoundTriggerResult {
/** Detail for logs, in whatever language the underlying failure happened to arrive in. */
private final String message;
+
+ /**
+ * The rolling-key index the accessory was found advertising at, or null if it was not found.
+ *
+ * Reported rather than acted on, deliberately. A sighting pins the alignment, which
+ * is what keeps the next scan cheap - but persisting it means Python and the database, and
+ * this package has neither. The caller hands it to
+ * {@code BeaconRepository#recordAccessorySighting}, which is where every other
+ * accessory-state write already lives.
+ *
+ *
Set whenever the scan matched, including when the GATT handshake then failed: the
+ * tag really was there, and that is true regardless of whether it made a noise.
+ */
+ private final Integer matchedKeyIndex;
+
+ /**
+ * An outcome from a stage that cannot know the index, which is every stage but the scan.
+ *
+ *
{@link BleGattSoundTrigger} is handed a device and told to talk to it; which candidate
+ * that device was is not its business and not in its scope. It reports the outcome, and
+ * {@link BleAccessorySoundTrigger#playSound} - the one place that holds both the candidate
+ * map and the device - attaches the index afterwards via
+ * {@link BleSoundTriggerUpdate#withMatchedKeyIndex}.
+ */
+ public BleSoundTriggerResult(
+ final BleSoundTriggerStatus status, final String protocol, final String message) {
+ this(status, protocol, message, null);
+ }
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerUpdate.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerUpdate.java
index ec47a0c5..a6a50447 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerUpdate.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerUpdate.java
@@ -26,4 +26,20 @@ public static BleSoundTriggerUpdate progress(final BleSoundTriggerPhase phase) {
public static BleSoundTriggerUpdate done(final BleSoundTriggerResult result) {
return new BleSoundTriggerUpdate(BleSoundTriggerPhase.DONE, result);
}
+
+ /**
+ * The same update with the index the scan matched at, if this is the terminal one.
+ *
+ *
Applied after the fact because the sighting happens at the end of the scan and the
+ * outcome only exists at the end of the GATT exchange - which may be several retries later,
+ * and may fail. Attaching it here means every DONE that followed a real sighting carries it,
+ * without the retry logic having to know the index exists.
+ */
+ public BleSoundTriggerUpdate withMatchedKeyIndex(final Integer keyIndex) {
+ if (this.phase != BleSoundTriggerPhase.DONE || keyIndex == null) {
+ return this;
+ }
+ return done(new BleSoundTriggerResult(this.result.getStatus(), this.result.getProtocol(),
+ this.result.getMessage(), keyIndex));
+ }
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
index 7c4d9800..f13525bb 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
@@ -25,6 +25,7 @@
import dev.wander.android.opentagviewer.db.room.entity.UserBeaconOptions;
import dev.wander.android.opentagviewer.db.util.BeaconCombinerUtil;
import dev.wander.android.opentagviewer.python.AccessoryRequest;
+import dev.wander.android.opentagviewer.python.AppDependencies;
import dev.wander.android.opentagviewer.python.ChaquopyPlistToAccessoryJsonConverter;
import dev.wander.android.opentagviewer.python.FetchResult;
import dev.wander.android.opentagviewer.python.icloud.AccessoryRecords;
@@ -527,6 +528,46 @@ public Observable> toAccessoryRequests(MapA BLE sighting is worth the same as a decrypted location report, and is persisted
+ * the same way: FindMy.py's {@code update_alignment} is how it is told about either, and
+ * {@code accessory_json} is where the result lives. Without this the twelve-hour candidate
+ * range that found the tag is re-derived from scratch on the next scan; with it, the next
+ * scan derives three keys.
+ *
+ * Failure is swallowed on purpose. This runs after a sound has already played (or
+ * failed to), and nothing the user asked for depends on it - the cost of losing it is a
+ * wider search next time, not a broken feature. It is emphatically not worth turning a
+ * successful ring into an error toast.
+ */
+ public Completable recordAccessorySighting(
+ final String beaconId, final int keyIndex, final long seenAtUnixMs) {
+ return Completable.fromRunnable(() -> {
+ final var dao = db.ownedBeaconDao();
+ final OwnedBeacon row = dao.getById(beaconId);
+
+ if (row == null || row.accessoryJson == null) {
+ Log.d(TAG, "Nothing to align for beaconId=" + beaconId + " - no accessory_json");
+ return;
+ }
+
+ final String updated = AppDependencies.accessoryMacResolver()
+ .recordSeen(row.accessoryJson, keyIndex, seenAtUnixMs);
+
+ if (updated == null) {
+ Log.d(TAG, "Could not record the sighting for beaconId=" + beaconId);
+ return;
+ }
+
+ dao.updateAccessoryJson(beaconId, updated);
+ Log.d(TAG, "Aligned beaconId=" + beaconId + " to key index " + keyIndex
+ + " from a Bluetooth sighting");
+ }).subscribeOn(Schedulers.io());
+ }
+
/**
* Persist a {@link FetchResult} from {@code PythonAppleService}: location reports
* go to the cache (delegating to {@link #storeToLocationCache}), and the freshly
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java
index 44a406e2..b80d2a59 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java
@@ -1,6 +1,6 @@
package dev.wander.android.opentagviewer.python;
-import java.util.List;
+import java.util.Map;
/**
* The BLE MAC address(es) an accessory might currently be advertising.
@@ -18,11 +18,30 @@ public interface AccessoryMacResolver {
/**
* @param accessoryJson the persisted {@code OwnedBeacon.accessoryJson} for this beacon.
- * @return the candidate MAC address(es), or an empty list if none could be resolved. An
- * unreadable or null {@code accessoryJson} reports empty rather than throwing, since a
- * beacon whose accessory JSON has not yet been backfilled (see
- * {@code OwnedBeacon.accessoryJson}) is a real state the caller must be able to show, not a
- * bug in this call.
+ * @return each candidate MAC address mapped to the key index it came from, or an
+ * empty map if none could be resolved. An unreadable or null {@code accessoryJson} reports
+ * empty rather than throwing, since a beacon whose accessory JSON has not yet been
+ * backfilled (see {@code OwnedBeacon.accessoryJson}) is a real state the caller must be able
+ * to show, not a bug in this call.
+ *
+ *
The index is what {@link #recordSeen} needs, and the reason this is a map rather than
+ * the list it was: the search runs with a twelve-hour margin either side of the believed
+ * alignment, and feeding a match back is what collapses the next call to a single index.
*/
- List currentMacAddresses(String accessoryJson);
+ Map currentMacAddresses(String accessoryJson);
+
+ /**
+ * Record that this accessory was seen advertising at {@code keyIndex}, and return its new
+ * serialized state for the caller to persist.
+ *
+ * A BLE sighting is an observation of the same kind as a decrypted location report, and
+ * worth the same thing: it pins the rolling-key alignment, so the next scan derives three
+ * keys instead of a twelve-hour range. Persisting it is the caller's job - see
+ * {@code BeaconRepository#recordAccessorySighting}.
+ *
+ * @return the re-serialized accessory, or null if it could not be recorded. Null is not
+ * worth failing a caller over: the sighting is an optimisation, and the sound either played
+ * or it did not regardless.
+ */
+ String recordSeen(String accessoryJson, int keyIndex, long seenAtUnixMs);
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java
index 68dae68c..89ed6e70 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java
@@ -91,9 +91,16 @@ public interface AnisetteFactory {
*
*
Built from {@link #accessoryMacResolver} rather than constructing its own, so replacing
* one in a test replaces what the other depends on too.
+ *
+ *
Null until asked for, which is what makes the sentence above true. Built eagerly
+ * here, it captured whichever resolver existed at class-init - a {@code final} field inside
+ * it - so {@link #replaceAccessoryMacResolver} swapped this class's field and left the
+ * trigger holding the real Chaquopy one. A test that stubbed only the resolver would then
+ * start CPython, which is the single thing that seam exists to avoid, and it would do it
+ * without failing: Chaquopy works on a device, so the test passes slowly rather than
+ * loudly.
*/
- private static AccessorySoundTrigger accessorySoundTrigger =
- BleAccessorySoundTrigger.forRealBluetooth(accessoryMacResolver);
+ private static AccessorySoundTrigger accessorySoundTrigger = null;
/**
* Strips personal identifiers out of a log before it is offered to anybody.
@@ -195,6 +202,9 @@ public static AccessoryMacResolver accessoryMacResolver() {
}
public static AccessorySoundTrigger accessorySoundTrigger() {
+ if (accessorySoundTrigger == null) {
+ accessorySoundTrigger = BleAccessorySoundTrigger.forRealBluetooth(accessoryMacResolver);
+ }
return accessorySoundTrigger;
}
@@ -233,6 +243,9 @@ public static void replaceHardwareDescriber(final HardwareDescriber replacement)
@VisibleForTesting
public static void replaceAccessoryMacResolver(final AccessoryMacResolver replacement) {
accessoryMacResolver = replacement;
+ // Dropped rather than rebuilt, so an explicit replaceAccessorySoundTrigger made after
+ // this one still wins. It is rebuilt from the new resolver on the next call.
+ accessorySoundTrigger = null;
}
@VisibleForTesting
@@ -263,7 +276,7 @@ public static void reset() {
serverTesterFactory = AnisetteServerTesterService::new;
hardwareDescriber = new ChaquopyHardwareDescriber();
accessoryMacResolver = new ChaquopyAccessoryMacResolver();
- accessorySoundTrigger = BleAccessorySoundTrigger.forRealBluetooth(accessoryMacResolver);
+ accessorySoundTrigger = null;
logRedactor = new ChaquopyLogRedactor();
bundleBuilder = new ChaquopyBundleBuilder();
icloudFactory = AppDependencies::openRealICloud;
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java
index c084ef98..6445b9cb 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java
@@ -5,9 +5,9 @@
import com.chaquo.python.PyObject;
import com.chaquo.python.Python;
-import java.util.ArrayList;
import java.util.Collections;
-import java.util.List;
+import java.util.HashMap;
+import java.util.Map;
/**
* The real resolver: calls {@code main.py:currentMacAddresses}, which delegates to
@@ -25,11 +25,11 @@ public class ChaquopyAccessoryMacResolver implements AccessoryMacResolver {
private static final String MODULE_MAIN = "main";
@Override
- public List currentMacAddresses(final String accessoryJson) {
+ public Map currentMacAddresses(final String accessoryJson) {
if (accessoryJson == null || accessoryJson.isEmpty()) {
// Not yet backfilled from the legacy plist - see OwnedBeacon.accessoryJson. A real
// state, not a failure, so this reports it the same way Python does: nothing found.
- return Collections.emptyList();
+ return Collections.emptyMap();
}
try {
@@ -38,19 +38,41 @@ public List currentMacAddresses(final String accessoryJson) {
if (returned == null) {
Log.w(TAG, "currentMacAddresses returned None (check python logs for details)");
- return Collections.emptyList();
+ return Collections.emptyMap();
}
- final List macs = new ArrayList<>();
- for (final PyObject mac : returned.asList()) {
- macs.add(mac.toString());
+ // Crossed once, here, and matched in Java from then on. A call per advertisement
+ // would pay the derivation and the marshalling for every device in range.
+ final Map candidates = new HashMap<>();
+ for (final Map.Entry entry : returned.asMap().entrySet()) {
+ candidates.put(entry.getKey().toString(), entry.getValue().toInt());
}
- return macs;
+ return candidates;
} catch (final Exception e) {
// Either Python has not started, or the accessory JSON could not be read. Neither
// is worth failing the caller over: it reads as "nothing to match against yet".
Log.w(TAG, "currentMacAddresses failed", e);
- return Collections.emptyList();
+ return Collections.emptyMap();
+ }
+ }
+
+ @Override
+ public String recordSeen(
+ final String accessoryJson, final int keyIndex, final long seenAtUnixMs) {
+ if (accessoryJson == null || accessoryJson.isEmpty()) {
+ return null;
+ }
+
+ try {
+ final var module = Python.getInstance().getModule(MODULE_MAIN);
+ final PyObject returned = module.callAttr(
+ "recordAccessorySeen", accessoryJson, keyIndex, seenAtUnixMs);
+
+ return returned == null ? null : returned.toString();
+ } catch (final Exception e) {
+ // Losing a sighting costs the next scan a wider search, nothing else.
+ Log.w(TAG, "recordAccessorySeen failed", e);
+ return null;
}
}
}
diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py
index 3012bbbf..d82e453f 100644
--- a/app/src/main/python/main.py
+++ b/app/src/main/python/main.py
@@ -3,7 +3,7 @@
import json
import time
import traceback
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
from io import BytesIO
import base64
import NSKeyedUnArchiver
@@ -1017,9 +1017,25 @@ def accessoryFromJson(accessoryJson: str) -> StoredAccessory:
return accessoryType.from_json(cast(Any, mapping))
-def currentMacAddresses(accessoryJson: str) -> list[str] | None:
+#: How far either side of the believed alignment to look for the accessory's current key.
+#
+# **Not zero, which is what a bare call would use.** Without a margin the search starts at the
+# alignment index, so an accessory whose true index has drifted *below* where alignment believes
+# it is can never be matched - it is simply absent from its own candidate set, with nothing
+# raising anywhere. FindMy.py's own `NearbyOfflineFindingDevice.is_from` takes the same
+# precaution with the same twelve hours, and its docstring records the failure being observed on
+# real hardware advertising a metre from the scanner.
+#
+# The cost is bounded because the app keeps alignment fresh: `getLastReports` writes
+# `updatedAccessoryJson` back after every fetch, aligned or empty. Twelve hours off a fresh
+# alignment is on the order of a hundred keys; the pathological cases in FindMy.py's own notes
+# are accessories that have *never* been aligned, which this app does not produce.
+_MAC_CANDIDATE_MARGIN = timedelta(hours=12)
+
+
+def currentMacAddresses(accessoryJson: str) -> dict[str, int] | None:
"""
- The BLE MAC address(es) this accessory might currently be advertising.
+ The BLE MAC address(es) this accessory might currently be advertising, each with its index.
Lets Java recognise an owned accessory's own advertisement in a BLE scan, so it can be
triggered directly (playing a sound) without going through Apple's Find My network - the
@@ -1030,17 +1046,50 @@ def currentMacAddresses(accessoryJson: str) -> list[str] | None:
range for *now* rather than a single index, to account for rollover uncertainty since the
last observed alignment.
+ **Each address maps to the key index it came from**, so a caller that matches one can hand
+ it straight to `recordAccessorySeen` - which is what keeps the next call cheap. Returning a
+ bare list would throw that away.
+
Returns None on failure so Java can decide how to recover - a missing or unreadable
- accessory is worth telling apart from "no keys", which would be an empty list.
+ accessory is worth telling apart from "no keys", which would be an empty mapping.
"""
try:
accessory = accessoryFromJson(accessoryJson)
- return sorted(accessory.current_mac_addresses())
+ return accessory.current_mac_addresses(margin=_MAC_CANDIDATE_MARGIN)
except Exception:
print(f"currentMacAddresses failed: {traceback.format_exc()}")
return None
+def recordAccessorySeen(
+ accessoryJson: str, keyIndex: int, seenAtUnixMs: int) -> str | None:
+ """
+ Tell an accessory it was seen advertising at `keyIndex`, and hand back its new state.
+
+ **This is what stops the margin above being paid for twice.** A BLE sighting is an
+ observation of exactly the same kind a decrypted location report is, and
+ `update_alignment` is how FindMy.py is told about either. Without this the twelve-hour
+ range is re-derived on every scan; with it, the call after a hit collapses to the three
+ keys of a single index.
+
+ The index for a *secondary* key is a lower bound - one covers 96 primary indices - which is
+ safe to pass on regardless, because `update_alignment` ignores anything below the alignment
+ it already holds.
+
+ Returns the re-serialized accessory for Java to write back to `OwnedBeacon.accessory_json`,
+ the same field and the same reason as `getLastReports`' `updatedAccessoryJson`. None on
+ failure, because a sighting that cannot be recorded is not worth failing a sound over.
+ """
+ try:
+ accessory = accessoryFromJson(accessoryJson)
+ accessory.update_alignment(
+ datetime.fromtimestamp(seenAtUnixMs / 1000, tz=timezone.utc), keyIndex)
+ return json.dumps(accessory.to_json())
+ except Exception:
+ print(f"recordAccessorySeen failed: {traceback.format_exc()}")
+ return None
+
+
def _isAlignmentWide(accessory: StoredAccessory, start, end) -> int:
"""Width of the key-index range a history fetch would search, or 0 if unknown."""
try:
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java
index e2a93c8b..3ebf9467 100644
--- a/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java
@@ -1,12 +1,16 @@
package dev.wander.android.opentagviewer.ble;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import org.junit.Test;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
@@ -29,11 +33,49 @@
public class BleAccessorySoundTriggerTest {
private static final String A_MAC = "AA:BB:CC:DD:EE:FF";
- private static final String A_DEVICE = "fake-device";
+ private static final String A_DEVICE = A_MAC;
+
+ /** The index the first candidate sits at, so a fed-back sighting is checkable. */
+ private static final int A_KEY_INDEX = 4321;
private static final long AWAIT_SECONDS = 5;
- private static AccessoryMacResolver resolverReturning(final List macs) {
- return accessoryJson -> macs;
+ /**
+ * A resolver over a fixed candidate set, counting calls and remembering sightings.
+ *
+ * A class rather than a lambda because {@link AccessoryMacResolver} gained a second
+ * method: a match is fed back through {@code recordSeen} so the next scan can collapse to a
+ * single key index, and a one-method interface cannot express both halves.
+ */
+ private static final class FakeResolver implements AccessoryMacResolver {
+ private final Map candidates;
+ private final AtomicInteger resolveCalls = new AtomicInteger();
+ private final List sightings = new ArrayList<>();
+
+ private FakeResolver(final Map candidates) {
+ this.candidates = candidates;
+ }
+
+ @Override
+ public Map currentMacAddresses(final String accessoryJson) {
+ this.resolveCalls.incrementAndGet();
+ return this.candidates;
+ }
+
+ @Override
+ public String recordSeen(
+ final String accessoryJson, final int keyIndex, final long seenAtUnixMs) {
+ this.sightings.add(keyIndex);
+ return "{\"aligned\":true}";
+ }
+ }
+
+ /** Candidates at made-up indices, so "which one matched" is visible in an assertion. */
+ private static FakeResolver resolverReturning(final List macs) {
+ final Map candidates = new LinkedHashMap<>();
+ for (int i = 0; i < macs.size(); i++) {
+ candidates.put(macs.get(i), A_KEY_INDEX + i);
+ }
+ return new FakeResolver(candidates);
}
private static BleSoundTriggerUpdate doneUpdate(final BleSoundTriggerStatus status) {
@@ -44,17 +86,15 @@ private static BleSoundTriggerUpdate doneUpdate(final BleSoundTriggerStatus stat
@Test
public void missingPermissionShortCircuitsBeforeResolvingAnyMac() throws InterruptedException {
- final AtomicInteger resolverCalls = new AtomicInteger();
- final AccessoryMacResolver resolver = accessoryJson -> {
- resolverCalls.incrementAndGet();
- return List.of(A_MAC);
- };
+ final FakeResolver resolver = resolverReturning(List.of(A_MAC));
+ final AtomicInteger resolverCalls = resolver.resolveCalls;
final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
resolver,
context -> false,
unreachableScanner(),
unreachableGattTrigger(),
+ s -> s,
3, 0L, 0L);
final List items = playSoundBlocking(trigger);
@@ -74,6 +114,7 @@ public void noCandidateMacsShortCircuitsBeforeScanning() throws InterruptedExcep
context -> true,
unreachableScanner(),
unreachableGattTrigger(),
+ s -> s,
3, 0L, 0L);
final List items = playSoundBlocking(trigger);
@@ -94,6 +135,7 @@ public void aSuccessfulRunEmitsScanningThenWhateverTheGattTriggerEmits() throws
BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.CONNECTING),
BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.TRIGGERING),
doneUpdate(BleSoundTriggerStatus.SUCCESS)),
+ s -> s,
3, 0L, 0L);
final List items = playSoundBlocking(trigger);
@@ -105,6 +147,79 @@ public void aSuccessfulRunEmitsScanningThenWhateverTheGattTriggerEmits() throws
assertEquals(BleSoundTriggerStatus.SUCCESS, items.get(3).getResult().getStatus());
}
+ /**
+ * A match reports the key index it matched at.
+ *
+ * Which is the whole reason the resolver returns a map. The candidate set is derived with
+ * a twelve-hour margin either side of the believed alignment, and without feeding a hit back
+ * that range is re-derived on every scan - about a hundred keys where three would do, on
+ * every cycle of a continuous ping. The caller persists it; this only has to report it.
+ */
+ @Test
+ public void asuccessfulMatchReportsWhichKeyIndexAnswered() throws InterruptedException {
+ final String anotherMac = "11:22:33:44:55:66";
+
+ final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
+ resolverReturning(List.of(A_MAC, anotherMac)),
+ context -> true,
+ // The *second* candidate answers, so a hardcoded first index cannot pass.
+ (context, macs, timeout) -> Single.just(anotherMac),
+ (context, device) -> Observable.just(doneUpdate(BleSoundTriggerStatus.SUCCESS)),
+ s -> s,
+ 3, 0L, 0L);
+
+ final List items = playSoundBlocking(trigger);
+
+ final BleSoundTriggerUpdate done = items.get(items.size() - 1);
+ assertEquals(BleSoundTriggerStatus.SUCCESS, done.getResult().getStatus());
+ assertEquals("the index of the candidate that actually answered",
+ Integer.valueOf(A_KEY_INDEX + 1), done.getResult().getMatchedKeyIndex());
+ }
+
+ /**
+ * And it reports it even when the sound then failed.
+ *
+ * The tag was there - that is what the scan proved, and it stays true whether or not the
+ * GATT exchange worked. Dropping the index on failure would mean the case most likely to be
+ * retried is also the one that keeps paying for the wide search.
+ */
+ @Test
+ public void afailedTriggerStillReportsThatTheTagWasSeen() throws InterruptedException {
+ final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
+ resolverReturning(List.of(A_MAC)),
+ context -> true,
+ (context, macs, timeout) -> Single.just(A_DEVICE),
+ (context, device) -> Observable.just(
+ doneUpdate(BleSoundTriggerStatus.NO_SOUND_SERVICE)),
+ s -> s,
+ 3, 0L, 0L);
+
+ final List items = playSoundBlocking(trigger);
+
+ final BleSoundTriggerUpdate done = items.get(items.size() - 1);
+ assertEquals(BleSoundTriggerStatus.NO_SOUND_SERVICE, done.getResult().getStatus());
+ assertEquals(Integer.valueOf(A_KEY_INDEX), done.getResult().getMatchedKeyIndex());
+ }
+
+ /** Nothing found means nothing to report - there is no sighting to record. */
+ @Test
+ public void anunfoundTagReportsNoKeyIndex() throws InterruptedException {
+ final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
+ resolverReturning(List.of(A_MAC)),
+ context -> true,
+ (context, macs, timeout) ->
+ Single.error(new NearbyAccessoryScanner.NotNearbyException()),
+ unreachableGattTrigger(),
+ s -> s,
+ 3, 0L, 0L);
+
+ final List items = playSoundBlocking(trigger);
+
+ final BleSoundTriggerUpdate done = items.get(items.size() - 1);
+ assertEquals(BleSoundTriggerStatus.NOT_NEARBY, done.getResult().getStatus());
+ assertNull(done.getResult().getMatchedKeyIndex());
+ }
+
@Test
public void theCandidateMacsPassedToTheScannerComeFromTheResolver() throws InterruptedException {
final AtomicInteger seenCandidateCount = new AtomicInteger(-1);
@@ -117,6 +232,7 @@ public void theCandidateMacsPassedToTheScannerComeFromTheResolver() throws Inter
return Single.just(A_DEVICE);
},
(context, device) -> Observable.just(doneUpdate(BleSoundTriggerStatus.SUCCESS)),
+ s -> s,
3, 0L, 0L);
playSoundBlocking(trigger);
@@ -138,6 +254,7 @@ public void aFailedAttemptIsRetriedUpToTheAttemptLimit() throws InterruptedExcep
gattCalls.incrementAndGet();
return Observable.just(doneUpdate(BleSoundTriggerStatus.FAILED));
},
+ s -> s,
3, 0L, 0L);
final List items = playSoundBlocking(trigger);
@@ -158,6 +275,7 @@ public void aSuccessfulRetryStopsFurtherAttempts() throws InterruptedException {
(context, device) -> Observable.just(gattCalls.incrementAndGet() == 1
? doneUpdate(BleSoundTriggerStatus.FAILED)
: doneUpdate(BleSoundTriggerStatus.SUCCESS)),
+ s -> s,
3, 0L, 0L);
final List items = playSoundBlocking(trigger);
@@ -179,6 +297,7 @@ public void noSoundServiceIsNeverRetried() throws InterruptedException {
gattCalls.incrementAndGet();
return Observable.just(doneUpdate(BleSoundTriggerStatus.NO_SOUND_SERVICE));
},
+ s -> s,
3, 0L, 0L);
final List items = playSoundBlocking(trigger);
@@ -198,6 +317,7 @@ public void aScannerTimeoutMapsToNotNearby() throws InterruptedException {
context -> true,
(context, macs, timeout) -> Single.error(new NearbyAccessoryScanner.NotNearbyException()),
unreachableGattTrigger(),
+ s -> s,
3, 0L, 0L);
final List items = playSoundBlocking(trigger);
@@ -213,6 +333,7 @@ public void anUnexpectedScannerErrorMapsToFailedRatherThanCrashing() throws Inte
context -> true,
(context, macs, timeout) -> Single.error(new IllegalStateException("radio is off")),
unreachableGattTrigger(),
+ s -> s,
3, 0L, 0L);
final TestObserver observer = trigger.playSound(null, "{}").test();
@@ -242,6 +363,7 @@ public void continuousPingRepeatsAfterEachCycleUntilDisposed() throws Interrupte
return Single.error(new NearbyAccessoryScanner.NotNearbyException());
},
unreachableGattTrigger(),
+ s -> s,
3, 0L, 1L); // 1ms pause - fast, but still an async repeatWhen delay
final Disposable subscription = trigger.playSoundContinuously(null, "{}")
@@ -267,6 +389,7 @@ public void disposingContinuousPingStopsFurtherCycles() throws InterruptedExcept
return Single.error(new NearbyAccessoryScanner.NotNearbyException());
},
unreachableGattTrigger(),
+ s -> s,
3, 0L, 1L);
final Disposable subscription = trigger.playSoundContinuously(null, "{}")
From e4bbfcc138ebacbabc62c0a3fa7ae92f9047766c Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 13:32:13 +0200
Subject: [PATCH 06/61] Restrict alignment corrections from a BLE sighting to
primary-key matches
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.
---
.../opentagviewer/DeviceInfoActivity.java | 4 +-
.../android/opentagviewer/MapsActivity.java | 4 +-
.../ble/BleAccessorySoundTrigger.java | 11 +-
.../ble/BleSoundTriggerResult.java | 24 ++--
.../ble/BleSoundTriggerUpdate.java | 10 +-
.../db/repo/BeaconRepository.java | 10 +-
.../python/AccessoryMacResolver.java | 27 +++--
.../python/ChaquopyAccessoryMacResolver.java | 6 +-
app/src/main/python/main.py | 69 ++++++++---
.../ble/BleAccessorySoundTriggerTest.java | 33 +++---
app/src/test/python/test_main.py | 109 ++++++++++++++++++
11 files changed, 240 insertions(+), 67 deletions(-)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index 838028b5..41f200f6 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -697,12 +697,12 @@ private void handlePlaySoundUpdate(final BleSoundTriggerUpdate update) {
*/
private void keepWhatTheSightingProved(final BleSoundTriggerUpdate update) {
if (update.getPhase() != BleSoundTriggerPhase.DONE
- || update.getResult().getMatchedKeyIndex() == null) {
+ || update.getResult().getMatchedMac() == null) {
return;
}
this.beaconRepo.recordAccessorySighting(
this.beaconId,
- update.getResult().getMatchedKeyIndex(),
+ update.getResult().getMatchedMac(),
System.currentTimeMillis())
.subscribe(() -> { }, error ->
Log.d(TAG, "Could not keep the alignment from a sighting", error));
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index eba557b7..8f386ba0 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -1460,12 +1460,12 @@ private void handleContinuousPingUpdate(final String beaconId, final BleSoundTri
private void keepWhatTheSightingProved(
final String beaconId, final BleSoundTriggerUpdate update) {
if (update.getPhase() != BleSoundTriggerPhase.DONE
- || update.getResult().getMatchedKeyIndex() == null) {
+ || update.getResult().getMatchedMac() == null) {
return;
}
this.beaconRepo.recordAccessorySighting(
beaconId,
- update.getResult().getMatchedKeyIndex(),
+ update.getResult().getMatchedMac(),
System.currentTimeMillis())
.subscribe(() -> { }, error ->
Log.d(TAG, "Could not keep the alignment from a sighting", error));
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
index 4bc7faa4..e80e7f53 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
@@ -170,13 +170,14 @@ public Observable playSound(final Context context, final
.findNearby(context, candidates.keySet(), SCAN_TIMEOUT_MS)
.toObservable()
.flatMap(device -> {
- // Which of the candidates answered - the index behind it is what
- // lets the caller pin the alignment and keep the next scan cheap.
- final Integer matched =
- candidates.get(this.addressOf.address(device));
+ // The address itself, not the index currentMacAddresses paired
+ // it with - see BleSoundTriggerResult on why only the caller
+ // (through Python, which alone can tell a primary key's index
+ // from a secondary one's) may turn this into an alignment write.
+ final String matchedMac = this.addressOf.address(device);
return this.triggerWithRetry(context, device, this.gattAttempts)
- .map(update -> update.withMatchedKeyIndex(matched));
+ .map(update -> update.withMatchedMac(matchedMac));
})
.onErrorReturn(BleAccessorySoundTrigger::asDoneUpdate));
}).subscribeOn(Schedulers.io());
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java
index 637c0fcb..3909e79b 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java
@@ -16,27 +16,35 @@ public class BleSoundTriggerResult {
private final String message;
/**
- * The rolling-key index the accessory was found advertising at, or null if it was not found.
+ * The BLE address the accessory was found advertising as, or null if it was not found.
*
- * Reported rather than acted on, deliberately. A sighting pins the alignment, which
- * is what keeps the next scan cheap - but persisting it means Python and the database, and
- * this package has neither. The caller hands it to
+ *
Reported rather than acted on, deliberately. A sighting can pin the alignment,
+ * which is what keeps the next scan cheap - but persisting it means Python and the database,
+ * and this package has neither. The caller hands it to
* {@code BeaconRepository#recordAccessorySighting}, which is where every other
* accessory-state write already lives.
*
+ *
The address rather than the key index {@code currentMacAddresses} paired it with.
+ * That index is only trustworthy when the address came from a primary key - a secondary
+ * key's index is a lower bound, not the true one - and this package has no way to tell the
+ * two apart; only {@code main.py:recordAccessorySeen} can, by re-deriving the key at this
+ * address and checking its type. Passing the raw index on would risk the caller trusting an
+ * index this package cannot vouch for.
+ *
*
Set whenever the scan matched, including when the GATT handshake then failed: the
* tag really was there, and that is true regardless of whether it made a noise.
*/
- private final Integer matchedKeyIndex;
+ private final String matchedMac;
/**
- * An outcome from a stage that cannot know the index, which is every stage but the scan.
+ * An outcome from a stage that cannot know which candidate answered, which is every stage
+ * but the scan.
*
*
{@link BleGattSoundTrigger} is handed a device and told to talk to it; which candidate
* that device was is not its business and not in its scope. It reports the outcome, and
* {@link BleAccessorySoundTrigger#playSound} - the one place that holds both the candidate
- * map and the device - attaches the index afterwards via
- * {@link BleSoundTriggerUpdate#withMatchedKeyIndex}.
+ * set and the device - attaches the address afterwards via
+ * {@link BleSoundTriggerUpdate#withMatchedMac}.
*/
public BleSoundTriggerResult(
final BleSoundTriggerStatus status, final String protocol, final String message) {
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerUpdate.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerUpdate.java
index a6a50447..5746350b 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerUpdate.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerUpdate.java
@@ -28,18 +28,18 @@ public static BleSoundTriggerUpdate done(final BleSoundTriggerResult result) {
}
/**
- * The same update with the index the scan matched at, if this is the terminal one.
+ * The same update with the address the scan matched, if this is the terminal one.
*
*
Applied after the fact because the sighting happens at the end of the scan and the
* outcome only exists at the end of the GATT exchange - which may be several retries later,
* and may fail. Attaching it here means every DONE that followed a real sighting carries it,
- * without the retry logic having to know the index exists.
+ * without the retry logic having to know the address exists.
*/
- public BleSoundTriggerUpdate withMatchedKeyIndex(final Integer keyIndex) {
- if (this.phase != BleSoundTriggerPhase.DONE || keyIndex == null) {
+ public BleSoundTriggerUpdate withMatchedMac(final String mac) {
+ if (this.phase != BleSoundTriggerPhase.DONE || mac == null) {
return this;
}
return done(new BleSoundTriggerResult(this.result.getStatus(), this.result.getProtocol(),
- this.result.getMessage(), keyIndex));
+ this.result.getMessage(), mac));
}
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
index f13525bb..bf1ceaff 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
@@ -544,7 +544,7 @@ public Observable> toAccessoryRequests(Map {
final var dao = db.ownedBeaconDao();
final OwnedBeacon row = dao.getById(beaconId);
@@ -555,16 +555,16 @@ public Completable recordAccessorySighting(
}
final String updated = AppDependencies.accessoryMacResolver()
- .recordSeen(row.accessoryJson, keyIndex, seenAtUnixMs);
+ .recordSeen(row.accessoryJson, mac, seenAtUnixMs);
if (updated == null) {
- Log.d(TAG, "Could not record the sighting for beaconId=" + beaconId);
+ // Also the ordinary outcome of a secondary-key-only match, not just a failure -
+ // see AccessoryMacResolver#recordSeen. Nothing to log as a problem here.
return;
}
dao.updateAccessoryJson(beaconId, updated);
- Log.d(TAG, "Aligned beaconId=" + beaconId + " to key index " + keyIndex
- + " from a Bluetooth sighting");
+ Log.d(TAG, "Aligned beaconId=" + beaconId + " from a Bluetooth sighting");
}).subscribeOn(Schedulers.io());
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java
index b80d2a59..6c5385d0 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java
@@ -31,17 +31,30 @@ public interface AccessoryMacResolver {
Map currentMacAddresses(String accessoryJson);
/**
- * Record that this accessory was seen advertising at {@code keyIndex}, and return its new
+ * Record that this accessory was seen advertising as {@code mac}, and return its new
* serialized state for the caller to persist.
*
* A BLE sighting is an observation of the same kind as a decrypted location report, and
- * worth the same thing: it pins the rolling-key alignment, so the next scan derives three
- * keys instead of a twelve-hour range. Persisting it is the caller's job - see
+ * can be worth the same thing: it can pin the rolling-key alignment, so the next scan
+ * derives three keys instead of a twelve-hour range. Persisting it is the caller's job - see
* {@code BeaconRepository#recordAccessorySighting}.
*
- * @return the re-serialized accessory, or null if it could not be recorded. Null is not
- * worth failing a caller over: the sighting is an optimisation, and the sound either played
- * or it did not regardless.
+ *
The address, not the index {@code currentMacAddresses} paired it with. That
+ * index is only trustworthy when {@code mac} came from a primary key - a secondary key's
+ * index is only a lower bound, not the true one - and this side of the bridge has no way to
+ * tell the two apart from the index alone. The real implementation re-derives the key at
+ * {@code mac} in Python, where the type is still known, and only accepts a primary match.
+ *
+ * @return the re-serialized accessory, or null if there was nothing worth recording - no
+ * match, only a secondary-key match, or a failure. Null is not worth failing a caller over:
+ * the sighting is an optimisation, and the sound either played or it did not regardless.
+ *
+ *
Defaulted to "records nothing" rather than a second required method, so a
+ * {@code currentMacAddresses}-only lambda - most of this interface's test doubles, which
+ * only ever care about the candidate set - keeps compiling. {@link ChaquopyAccessoryMacResolver}
+ * overrides it for real.
*/
- String recordSeen(String accessoryJson, int keyIndex, long seenAtUnixMs);
+ default String recordSeen(String accessoryJson, String mac, long seenAtUnixMs) {
+ return null;
+ }
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java
index 6445b9cb..0c828b22 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java
@@ -58,15 +58,15 @@ public Map currentMacAddresses(final String accessoryJson) {
@Override
public String recordSeen(
- final String accessoryJson, final int keyIndex, final long seenAtUnixMs) {
- if (accessoryJson == null || accessoryJson.isEmpty()) {
+ final String accessoryJson, final String mac, final long seenAtUnixMs) {
+ if (accessoryJson == null || accessoryJson.isEmpty() || mac == null) {
return null;
}
try {
final var module = Python.getInstance().getModule(MODULE_MAIN);
final PyObject returned = module.callAttr(
- "recordAccessorySeen", accessoryJson, keyIndex, seenAtUnixMs);
+ "recordAccessorySeen", accessoryJson, mac, seenAtUnixMs);
return returned == null ? null : returned.toString();
} catch (final Exception e) {
diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py
index d82e453f..00ae2652 100644
--- a/app/src/main/python/main.py
+++ b/app/src/main/python/main.py
@@ -10,6 +10,7 @@
from findmy import FindMyAccessory, MobileMeDelegateError
from findmy.accessory import FixedRollingKeyPairAccessory
+from findmy.keys import KeyPairType
from findmy.reports import (
RemoteAnisetteProvider,
AppleAccount,
@@ -1061,30 +1062,72 @@ def currentMacAddresses(accessoryJson: str) -> dict[str, int] | None:
return None
-def recordAccessorySeen(
- accessoryJson: str, keyIndex: int, seenAtUnixMs: int) -> str | None:
+def recordAccessorySeen(accessoryJson: str, mac: str, seenAtUnixMs: int) -> str | None:
"""
- Tell an accessory it was seen advertising at `keyIndex`, and hand back its new state.
+ Tell an accessory it was seen advertising as `mac`, and hand back its new state.
- **This is what stops the margin above being paid for twice.** A BLE sighting is an
- observation of exactly the same kind a decrypted location report is, and
- `update_alignment` is how FindMy.py is told about either. Without this the twelve-hour
+ **This is what stops the margin above being paid for twice.** A BLE sighting is worth
+ realigning to, the same as a decrypted location report is. Without this the twelve-hour
range is re-derived on every scan; with it, the call after a hit collapses to the three
keys of a single index.
- The index for a *secondary* key is a lower bound - one covers 96 primary indices - which is
- safe to pass on regardless, because `update_alignment` ignores anything below the alignment
- it already holds.
+ **Takes the address rather than the index `currentMacAddresses` returned for it, and that
+ difference is load-bearing.** That index is only trustworthy when the address came from a
+ *primary* key: a primary index is unique, one key per index, so a match against it proves
+ the true index outright. A secondary key covers 96 consecutive primary indices - see
+ `_AccessoryKeyGenerator._secondary_keys_at` - so its index is only the first one the search
+ happened to reach, not the true one. Fed to `update_alignment` without checking, that index
+ can ratchet alignment past the true index in the wrong direction - 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 has to happen here rather than by filtering the map
+ `currentMacAddresses` returns, because an address derived from a secondary key is still
+ worth *scanning for* - only not worth *aligning to*.
+
+ **Does not go through `update_alignment`, and that is also deliberate.** It only ever moves
+ forward - correct for a fetch, where every index it sees came from searching ahead of where
+ alignment already believes it is, so "never seen a lower one" is a safe rule there. A BLE
+ match is not built that way: it comes from a wide, symmetric window, so a primary match can
+ legitimately land below the stored alignment - proof that alignment had already drifted too
+ far ahead, from an earlier secondary-key mistake or otherwise. Refusing to correct downward
+ would leave that drift permanent, which is the whole failure this function exists to undo.
+ So a primary match's index is written to the accessory's serialized state directly, in
+ either direction.
+
+ So this re-derives the key at `mac` itself, from scratch, and only accepts a match through
+ its primary key. A secondary-only match, or no match at all (the candidate set may have
+ moved on since the scan that found `mac`), records nothing.
Returns the re-serialized accessory for Java to write back to `OwnedBeacon.accessory_json`,
the same field and the same reason as `getLastReports`' `updatedAccessoryJson`. None on
- failure, because a sighting that cannot be recorded is not worth failing a sound over.
+ failure or on nothing worth recording, because a sighting that cannot be recorded is not
+ worth failing a sound over.
"""
try:
accessory = accessoryFromJson(accessoryJson)
- accessory.update_alignment(
- datetime.fromtimestamp(seenAtUnixMs / 1000, tz=timezone.utc), keyIndex)
- return json.dumps(accessory.to_json())
+ if not isinstance(accessory, FindMyAccessory):
+ # A self-generated accessory's keys don't rotate (update_alignment is a no-op for
+ # it) - there is no drift here for this to fix.
+ return None
+
+ seen_at = datetime.fromtimestamp(seenAtUnixMs / 1000, tz=timezone.utc)
+ mac = mac.upper()
+
+ matched_index = None
+ for key, index in accessory.current_keys(seen_at, margin=_MAC_CANDIDATE_MARGIN).items():
+ if key.key_type == KeyPairType.PRIMARY and key.mac_address == mac:
+ matched_index = index
+ break
+
+ if matched_index is None:
+ return None
+
+ mapping = json.loads(accessoryJson)
+ if mapping.get("alignment_index") == matched_index:
+ return None
+
+ mapping["alignment_index"] = matched_index
+ mapping["alignment_date"] = seen_at.isoformat()
+ return json.dumps(mapping)
except Exception:
print(f"recordAccessorySeen failed: {traceback.format_exc()}")
return None
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java
index 3ebf9467..b9db1f05 100644
--- a/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java
@@ -49,7 +49,7 @@ public class BleAccessorySoundTriggerTest {
private static final class FakeResolver implements AccessoryMacResolver {
private final Map candidates;
private final AtomicInteger resolveCalls = new AtomicInteger();
- private final List sightings = new ArrayList<>();
+ private final List sightings = new ArrayList<>();
private FakeResolver(final Map candidates) {
this.candidates = candidates;
@@ -63,8 +63,8 @@ public Map currentMacAddresses(final String accessoryJson) {
@Override
public String recordSeen(
- final String accessoryJson, final int keyIndex, final long seenAtUnixMs) {
- this.sightings.add(keyIndex);
+ final String accessoryJson, final String mac, final long seenAtUnixMs) {
+ this.sightings.add(mac);
return "{\"aligned\":true}";
}
}
@@ -148,21 +148,20 @@ public void aSuccessfulRunEmitsScanningThenWhateverTheGattTriggerEmits() throws
}
/**
- * A match reports the key index it matched at.
+ * A match reports the address that answered, not the index it was resolved with.
*
- * Which is the whole reason the resolver returns a map. The candidate set is derived with
- * a twelve-hour margin either side of the believed alignment, and without feeding a hit back
- * that range is re-derived on every scan - about a hundred keys where three would do, on
- * every cycle of a continuous ping. The caller persists it; this only has to report it.
+ *
Only Python can tell a primary key's index from a secondary key's - see
+ * {@link AccessoryMacResolver#recordSeen} - so this package reports the raw address and
+ * leaves that judgment to the caller's next call across the bridge.
*/
@Test
- public void asuccessfulMatchReportsWhichKeyIndexAnswered() throws InterruptedException {
+ public void aSuccessfulMatchReportsWhichMacAnswered() throws InterruptedException {
final String anotherMac = "11:22:33:44:55:66";
final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
resolverReturning(List.of(A_MAC, anotherMac)),
context -> true,
- // The *second* candidate answers, so a hardcoded first index cannot pass.
+ // The *second* candidate answers, so a hardcoded first address cannot pass.
(context, macs, timeout) -> Single.just(anotherMac),
(context, device) -> Observable.just(doneUpdate(BleSoundTriggerStatus.SUCCESS)),
s -> s,
@@ -172,16 +171,16 @@ public void asuccessfulMatchReportsWhichKeyIndexAnswered() throws InterruptedExc
final BleSoundTriggerUpdate done = items.get(items.size() - 1);
assertEquals(BleSoundTriggerStatus.SUCCESS, done.getResult().getStatus());
- assertEquals("the index of the candidate that actually answered",
- Integer.valueOf(A_KEY_INDEX + 1), done.getResult().getMatchedKeyIndex());
+ assertEquals("the address of the candidate that actually answered",
+ anotherMac, done.getResult().getMatchedMac());
}
/**
* And it reports it even when the sound then failed.
*
* The tag was there - that is what the scan proved, and it stays true whether or not the
- * GATT exchange worked. Dropping the index on failure would mean the case most likely to be
- * retried is also the one that keeps paying for the wide search.
+ * GATT exchange worked. Dropping the address on failure would mean the case most likely to
+ * be retried is also the one that keeps paying for the wide search.
*/
@Test
public void afailedTriggerStillReportsThatTheTagWasSeen() throws InterruptedException {
@@ -198,12 +197,12 @@ public void afailedTriggerStillReportsThatTheTagWasSeen() throws InterruptedExce
final BleSoundTriggerUpdate done = items.get(items.size() - 1);
assertEquals(BleSoundTriggerStatus.NO_SOUND_SERVICE, done.getResult().getStatus());
- assertEquals(Integer.valueOf(A_KEY_INDEX), done.getResult().getMatchedKeyIndex());
+ assertEquals(A_MAC, done.getResult().getMatchedMac());
}
/** Nothing found means nothing to report - there is no sighting to record. */
@Test
- public void anunfoundTagReportsNoKeyIndex() throws InterruptedException {
+ public void anUnfoundTagReportsNoMac() throws InterruptedException {
final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
resolverReturning(List.of(A_MAC)),
context -> true,
@@ -217,7 +216,7 @@ public void anunfoundTagReportsNoKeyIndex() throws InterruptedException {
final BleSoundTriggerUpdate done = items.get(items.size() - 1);
assertEquals(BleSoundTriggerStatus.NOT_NEARBY, done.getResult().getStatus());
- assertNull(done.getResult().getMatchedKeyIndex());
+ assertNull(done.getResult().getMatchedMac());
}
@Test
diff --git a/app/src/test/python/test_main.py b/app/src/test/python/test_main.py
index 87283c15..a8f2e3a3 100644
--- a/app/src/test/python/test_main.py
+++ b/app/src/test/python/test_main.py
@@ -19,6 +19,7 @@
import pytest
import main
+from findmy.keys import KeyPairType
RESOURCES = Path(__file__).resolve().parents[1] / "resources"
BEACON_PLISTS = sorted(RESOURCES.glob("*/OwnedBeacons/*.plist"))
@@ -205,6 +206,114 @@ def test_current_mac_addresses_refuses_an_unknown_accessory_type():
json.dumps({"type": "something_from_the_future"})) is None
+# --------------------------------------------------------------------------
+# recordAccessorySeen
+#
+# What keeps a wide currentMacAddresses margin from being paid for on every scan: a match
+# against a *primary* key realigns the stored index, in either direction. A secondary key's
+# index is only a lower bound and must not be trusted the same way.
+# --------------------------------------------------------------------------
+
+_ALIGNMENT_DATE = datetime(2026, 1, 1, tzinfo=timezone.utc)
+
+
+def _paired_accessory(alignment_index: int) -> dict:
+ """A `FindMyAccessory` mapping with fixed, deterministic key material.
+
+ Real master/session keys, so the derived MACs below are the actual ones a scan would see -
+ not a fake fixture standing in for them. Alignment is planted away from index 0 so a
+ correction has somewhere to move both above and below.
+ """
+ from findmy import FindMyAccessory
+
+ accessory = FindMyAccessory(
+ master_key=b"\x11" * 28,
+ skn=b"\x22" * 32,
+ sks=b"\x33" * 32,
+ paired_at=_ALIGNMENT_DATE,
+ name="Test tag",
+ alignment_date=_ALIGNMENT_DATE,
+ alignment_index=alignment_index,
+ )
+ return accessory.to_json()
+
+
+def _mac_at(accessoryJson: dict, index: int, key_type) -> str:
+ from findmy import FindMyAccessory
+
+ accessory = FindMyAccessory.from_json(accessoryJson)
+ for key in accessory.keys_at(index):
+ if key.key_type == key_type:
+ return key.mac_address
+ raise AssertionError(f"no {key_type} key at index {index}")
+
+
+def _ms(dt: datetime) -> int:
+ return int(dt.timestamp() * 1000)
+
+
+def test_recordAccessorySeen_realigns_downward_from_a_primary_match():
+ """The case the whole feature exists for: alignment drifted ahead of the truth."""
+ stored = _paired_accessory(alignment_index=2880)
+ true_index = 2850 # inside the 12h margin, below the stored (wrong) alignment
+ mac = _mac_at(stored, true_index, KeyPairType.PRIMARY)
+
+ corrected = main.recordAccessorySeen(json.dumps(stored), mac, _ms(_ALIGNMENT_DATE))
+
+ assert corrected is not None
+ parsed = json.loads(corrected)
+ assert parsed["alignment_index"] == true_index
+ assert parsed["alignment_date"] == _ALIGNMENT_DATE.isoformat()
+
+
+def test_recordAccessorySeen_realigns_upward_from_a_primary_match():
+ stored = _paired_accessory(alignment_index=2880)
+ true_index = 2910 # inside the 12h margin, above the stored alignment
+ mac = _mac_at(stored, true_index, KeyPairType.PRIMARY)
+
+ corrected = main.recordAccessorySeen(json.dumps(stored), mac, _ms(_ALIGNMENT_DATE))
+
+ assert corrected is not None
+ assert json.loads(corrected)["alignment_index"] == true_index
+
+
+def test_recordAccessorySeen_is_a_noop_when_already_aligned():
+ """The common case, once alignment has healed: no write on every sighting thereafter."""
+ stored = _paired_accessory(alignment_index=2880)
+ mac = _mac_at(stored, 2880, KeyPairType.PRIMARY)
+
+ assert main.recordAccessorySeen(json.dumps(stored), mac, _ms(_ALIGNMENT_DATE)) is None
+
+
+def test_recordAccessorySeen_ignores_a_secondary_match():
+ """A secondary key is shared by 96 primary indices - realigning to its first match could
+ move alignment in the wrong direction entirely, so only a primary match may correct it."""
+ stored = _paired_accessory(alignment_index=2880)
+ mac = _mac_at(stored, 2850, KeyPairType.SECONDARY)
+
+ assert main.recordAccessorySeen(json.dumps(stored), mac, _ms(_ALIGNMENT_DATE)) is None
+
+
+def test_recordAccessorySeen_returns_none_for_an_unmatched_address():
+ stored = _paired_accessory(alignment_index=2880)
+
+ assert main.recordAccessorySeen(
+ json.dumps(stored), "00:00:00:00:00:00", _ms(_ALIGNMENT_DATE)) is None
+
+
+def test_recordAccessorySeen_ignores_a_self_generated_tag():
+ """A fixed key set never rotates - update_alignment is a no-op for it too - so there is no
+ drift here for this to fix."""
+ macs = main.currentMacAddresses(json.dumps(_CUSTOM_ACCESSORY))
+
+ assert main.recordAccessorySeen(
+ json.dumps(_CUSTOM_ACCESSORY), next(iter(macs)), _ms(_ALIGNMENT_DATE)) is None
+
+
+def test_recordAccessorySeen_returns_none_on_garbage():
+ assert main.recordAccessorySeen("not an accessory at all", "00:00:00:00:00:00", 0) is None
+
+
def test_convertPlistToJson_returns_none_on_garbage():
"""Failure must be None, not an exception - the caller retries later."""
assert main.convertPlistToJson("not a plist at all") is None
From 056d758c44ebd85ad2e538ea49c49162a17a1fbb Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 18:34:02 +0200
Subject: [PATCH 07/61] Refuse a sighting dated before the stored alignment
Bypassing update_alignment for the downward index move also bypassed
its backward-time guard. A device clock rolled back (manual change,
bad carrier time) could persist a (past date, current index) pair, and
once the clock corrected, the index extrapolated from that past date
would overshoot the true one - eventually past the search margin, at
which point the tag drops out of its own candidate set. The guard is
now kept: a sighting older than the stored alignment date records
nothing.
---
app/src/main/python/main.py | 8 ++++++++
app/src/test/python/test_main.py | 13 +++++++++++++
2 files changed, 21 insertions(+)
diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py
index 00ae2652..f587dd0a 100644
--- a/app/src/main/python/main.py
+++ b/app/src/main/python/main.py
@@ -1125,6 +1125,14 @@ def recordAccessorySeen(accessoryJson: str, mac: str, seenAtUnixMs: int) -> str
if mapping.get("alignment_index") == matched_index:
return None
+ # Bypassing update_alignment for the downward index move must not also bypass its
+ # backward-time guard: a device clock rolled back (manual change, bad carrier time)
+ # would otherwise persist a (past date, current index) pair, and once the clock
+ # corrects, the index extrapolated from that past date overshoots the true one.
+ stored_date = mapping.get("alignment_date")
+ if stored_date is not None and seen_at < datetime.fromisoformat(stored_date):
+ return None
+
mapping["alignment_index"] = matched_index
mapping["alignment_date"] = seen_at.isoformat()
return json.dumps(mapping)
diff --git a/app/src/test/python/test_main.py b/app/src/test/python/test_main.py
index a8f2e3a3..627f61f5 100644
--- a/app/src/test/python/test_main.py
+++ b/app/src/test/python/test_main.py
@@ -294,6 +294,19 @@ def test_recordAccessorySeen_ignores_a_secondary_match():
assert main.recordAccessorySeen(json.dumps(stored), mac, _ms(_ALIGNMENT_DATE)) is None
+def test_recordAccessorySeen_refuses_a_sighting_dated_before_the_stored_alignment():
+ """The backward-time guard update_alignment has, kept when bypassing it: a rolled-back
+ device clock must not persist a (past date, current index) pair - the index extrapolated
+ from that past date would overshoot once the clock corrects."""
+ stored = _paired_accessory(alignment_index=2880)
+ true_index = 2850
+ mac = _mac_at(stored, true_index, KeyPairType.PRIMARY)
+
+ an_hour_before_alignment = _ALIGNMENT_DATE - timedelta(hours=1)
+ assert main.recordAccessorySeen(
+ json.dumps(stored), mac, _ms(an_hour_before_alignment)) is None
+
+
def test_recordAccessorySeen_returns_none_for_an_unmatched_address():
stored = _paired_accessory(alignment_index=2880)
From 173dd10d5befad7a8a1629086123fd7eb15c79a2 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Tue, 25 Aug 2026 22:52:25 +0200
Subject: [PATCH 08/61] Find a tag whose alignment has drifted, and check one
index when we know it
Ringing resolves candidate MAC addresses from the stored key alignment, so
everything that makes that window wrong or too narrow makes the ring button
fail with "not nearby" for a tag lying on the table. Four changes, all in the
derivation rather than in the ring path itself:
The candidate margin is now derived rather than guessed. One secondary key
covers 192 primary indices, which is 48 hours, so a sighting matched through a
secondary key says nothing more precise than "somewhere in those 48 hours".
Twelve hours was narrower than what the data can actually support, and a tag
last aligned more than twelve hours ago simply stopped being found.
A window wider than 1000 indices is bounded to its newest slice instead of
refused. Refusing meant a long separated tag could never be found again, and
the tag most in need of finding is exactly the one nobody has heard from in a
while.
A secondary key match now raises the stored alignment floor, upward only. That
is what lets a tag recover on its own: each sighting narrows the window that
the next one starts from, instead of every scan paying for the same 48 hours
forever.
Finally, currentMacAddresses now pairs each address with the index it came
from, and recordSeen takes that index as a hint: three derivations at one
index rather than the whole window re-derived. Measured at 0.02s against
1.02s for the same answer, which is what took the alignment write from
"noticeable on a phone" to free. The ring path passes null, because a found
device tells us its address and not which index answered, and null falls back
to the old whole window check.
---
.../opentagviewer/DeviceInfoActivity.java | 6 +-
.../android/opentagviewer/MapsActivity.java | 6 +-
.../ble/BleAccessorySoundTrigger.java | 14 +-
.../db/repo/BeaconRepository.java | 5 +-
.../python/AccessoryMacResolver.java | 22 +-
.../python/ChaquopyAccessoryMacResolver.java | 5 +-
app/src/main/python/main.py | 162 ++++++++++++--
.../ble/BleAccessorySoundTriggerTest.java | 29 ++-
app/src/test/python/test_main.py | 202 +++++++++++++++++-
9 files changed, 420 insertions(+), 31 deletions(-)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index 41f200f6..79080167 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -703,7 +703,11 @@ private void keepWhatTheSightingProved(final BleSoundTriggerUpdate update) {
this.beaconRepo.recordAccessorySighting(
this.beaconId,
update.getResult().getMatchedMac(),
- System.currentTimeMillis())
+ System.currentTimeMillis(),
+ // No hint: the ring path knows which address answered but not which
+ // index it came from - see BleSoundTriggerResult. Python falls back to
+ // checking the whole window, which is what it did before hints existed.
+ null)
.subscribe(() -> { }, error ->
Log.d(TAG, "Could not keep the alignment from a sighting", error));
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index 8f386ba0..b5318fd8 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -1466,7 +1466,11 @@ private void keepWhatTheSightingProved(
this.beaconRepo.recordAccessorySighting(
beaconId,
update.getResult().getMatchedMac(),
- System.currentTimeMillis())
+ System.currentTimeMillis(),
+ // No hint: the ring path knows which address answered but not which
+ // index it came from - see BleSoundTriggerResult. Python falls back to
+ // checking the whole window, which is what it did before hints existed.
+ null)
.subscribe(() -> { }, error ->
Log.d(TAG, "Could not keep the alignment from a sighting", error));
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
index e80e7f53..3c3df140 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java
@@ -159,7 +159,19 @@ public Observable playSound(final Context context, final
// only moves when the fifteen-minute key interval ticks.
final Map candidates =
this.macResolver.currentMacAddresses(accessoryJson);
- if (candidates.isEmpty()) {
+
+ // **Null and empty mean the same thing here, and null must not be dereferenced.**
+ // The interface permits null for an accessory the resolver cannot read, and for
+ // one whose candidate window is too wide to be worth deriving - an owner's own
+ // Apple device reaches this code through "show my own Apple devices" and is exactly
+ // that, since a phone has no rolling-key alignment. Either way there is no address
+ // to scan for, which is what NO_CANDIDATE_MACS already says.
+ //
+ // Latent rather than observed: the Chaquopy implementation maps Python's None to an
+ // empty map, so no build has thrown here - but the signature permits null, and a
+ // throw inside this chain would surface as an error where "cannot resolve this one"
+ // is the honest answer.
+ if (candidates == null || candidates.isEmpty()) {
return Observable.just(BleSoundTriggerUpdate.done(new BleSoundTriggerResult(
BleSoundTriggerStatus.NO_CANDIDATE_MACS, null,
"Could not resolve a current MAC address for this accessory", null)));
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
index bf1ceaff..c0332c58 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
@@ -544,7 +544,8 @@ public Observable> toAccessoryRequests(Map {
final var dao = db.ownedBeaconDao();
final OwnedBeacon row = dao.getById(beaconId);
@@ -555,7 +556,7 @@ public Completable recordAccessorySighting(
}
final String updated = AppDependencies.accessoryMacResolver()
- .recordSeen(row.accessoryJson, mac, seenAtUnixMs);
+ .recordSeen(row.accessoryJson, mac, seenAtUnixMs, hintIndex);
if (updated == null) {
// Also the ordinary outcome of a secondary-key-only match, not just a failure -
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java
index 6c5385d0..e884130a 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java
@@ -39,11 +39,20 @@ public interface AccessoryMacResolver {
* derives three keys instead of a twelve-hour range. Persisting it is the caller's job - see
* {@code BeaconRepository#recordAccessorySighting}.
*
- * The address, not the index {@code currentMacAddresses} paired it with. That
- * index is only trustworthy when {@code mac} came from a primary key - a secondary key's
- * index is only a lower bound, not the true one - and this side of the bridge has no way to
- * tell the two apart from the index alone. The real implementation re-derives the key at
- * {@code mac} in Python, where the type is still known, and only accepts a primary match.
+ *
The address decides, the index is only a hint. An index is trustworthy only
+ * when {@code mac} came from a primary key - a secondary key's index is a lower bound, not
+ * the true one - and this side of the bridge cannot tell the two apart. So Python re-derives
+ * the keys itself and reads the type there; {@code hintIndex} only says where to look
+ * first, and a wrong one costs nothing but the wide search that used to happen anyway.
+ *
+ *
The hint is what makes this affordable to call. Checking one index is three key
+ * derivations; searching the whole candidate window is around 1150, measured at 1.15s on
+ * desktop and several times that under Chaquopy. Called on the sighting cadence without it,
+ * the app sat at 135% CPU with two tags in range until Android killed it for not answering
+ * input.
+ *
+ * @param hintIndex the index {@code currentMacAddresses} paired {@code mac} with, or null
+ * when the caller does not know - the search then runs as it did before.
*
* @return the re-serialized accessory, or null if there was nothing worth recording - no
* match, only a secondary-key match, or a failure. Null is not worth failing a caller over:
@@ -54,7 +63,8 @@ public interface AccessoryMacResolver {
* only ever care about the candidate set - keeps compiling. {@link ChaquopyAccessoryMacResolver}
* overrides it for real.
*/
- default String recordSeen(String accessoryJson, String mac, long seenAtUnixMs) {
+ default String recordSeen(
+ String accessoryJson, String mac, long seenAtUnixMs, Integer hintIndex) {
return null;
}
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java
index 0c828b22..70e02906 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java
@@ -58,7 +58,8 @@ public Map currentMacAddresses(final String accessoryJson) {
@Override
public String recordSeen(
- final String accessoryJson, final String mac, final long seenAtUnixMs) {
+ final String accessoryJson, final String mac, final long seenAtUnixMs,
+ final Integer hintIndex) {
if (accessoryJson == null || accessoryJson.isEmpty() || mac == null) {
return null;
}
@@ -66,7 +67,7 @@ public String recordSeen(
try {
final var module = Python.getInstance().getModule(MODULE_MAIN);
final PyObject returned = module.callAttr(
- "recordAccessorySeen", accessoryJson, mac, seenAtUnixMs);
+ "recordAccessorySeen", accessoryJson, mac, seenAtUnixMs, hintIndex);
return returned == null ? null : returned.toString();
} catch (final Exception e) {
diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py
index f587dd0a..e5dfb49b 100644
--- a/app/src/main/python/main.py
+++ b/app/src/main/python/main.py
@@ -1027,11 +1027,62 @@ def accessoryFromJson(accessoryJson: str) -> StoredAccessory:
# precaution with the same twelve hours, and its docstring records the failure being observed on
# real hardware advertising a metre from the scanner.
#
-# The cost is bounded because the app keeps alignment fresh: `getLastReports` writes
-# `updatedAccessoryJson` back after every fetch, aligned or empty. Twelve hours off a fresh
-# alignment is on the order of a hundred keys; the pathological cases in FindMy.py's own notes
-# are accessories that have *never* been aligned, which this app does not produce.
-_MAC_CANDIDATE_MARGIN = timedelta(hours=12)
+# **Forty-eight hours, and the number is derived rather than guessed.** Alignment is written
+# from `min(key_to_ind[key])` in the pinned FindMy.py's `reports.py` - deliberately the lowest
+# index a matched key could belong to, because underestimating is the safe direction. For a
+# secondary key that is an underestimate of real size: `keys_at` offers two secondary keys per
+# index (`ind // 96 + 1` and `+ 2`), so one secondary key spans 192 primary indices. A report
+# decrypted against a secondary key can therefore leave alignment up to 192 indices - 48 hours -
+# below the truth, and stay there until a primary match corrects it.
+#
+# So the margin has to reach 48 hours or a tag aligned that way is absent from its own candidate
+# set. Measured before this was understood: a tag beside the phone at -24 dBm, advertising
+# steadily, sat 58 indices above where alignment believed "now" was, and the twelve hours
+# FindMy.py's own `is_from` uses reaches only 48 indices.
+#
+# The margin is what lets such a tag be picked up again at all; `recordAccessorySeen`'s
+# secondary-key floor is what pulls alignment back up afterwards, so the full width is only
+# needed until the first sighting lands.
+#
+# Twenty-four hours off a fresh alignment is on the order of two hundred keys, which is nothing. The
+# cost is only bounded while the alignment *is* fresh, and this app does produce accessories
+# where it is not: enabling "show my own Apple devices" puts a phone in the list, and a phone
+# has no rolling-key alignment to be fresh. Measured on one that was switched off, the window
+# came to 39636 indices - over a year of keys, derived on a blocking call. See
+# `_MAC_CANDIDATE_MAX_INDICES`, which is what stops that being attempted.
+_MAC_CANDIDATE_MARGIN = timedelta(hours=48)
+
+#: How wide a candidate window may be before deriving it is refused outright.
+#
+# **This is a guard against one entry costing every other entry its scan.** Callers ask per
+# accessory, in a loop, and the derivation is blocking EC work with no interruption point. If
+# one accessory takes minutes, the loop never reaches the ones after it and the scan never
+# starts - so every tag stops being seen, with nothing failing anywhere to say why.
+#
+# The width is set by how *stale* the alignment is, not by whether there is one. Measured on
+# desktop CPython, which is several times faster than Chaquopy on a phone:
+#
+# alignment stale by width derivation
+# 1 day 144 0.5 s
+# 7 days 720 2.3 s
+# 30 days 2,928 9.3 s
+# 120 days 11,568 36.6 s
+# 400 days 38,448 121.9 s
+#
+# Linear, about 3.2 ms per index. The 39,636-index case that prompted this was an owner's own
+# phone, switched off, pulled in by "show my own Apple devices" - a phone has no rolling-key
+# alignment and never gains one.
+#
+# A thousand is roughly a week of staleness: enough for a tag that has missed a few fetches,
+# and a couple of seconds at worst. An accessory past it is skipped rather than searched.
+#
+# **The better fix is to bound the range instead of refusing it**, taking the newest N indices
+# rather than the whole span. A tag that is advertising right now has been running, so its true
+# index tracks the wall clock and sits at the top of the window; the bottom is only reachable by
+# a tag that was switched off for months, which is not advertising and so has nothing to match
+# anyway. That wants a `max_indices` on `current_mac_addresses` in the pinned FindMy.py rather
+# than a second key walk here, so it is deliberately not done in this commit.
+_MAC_CANDIDATE_MAX_INDICES = 1000
def currentMacAddresses(accessoryJson: str) -> dict[str, int] | None:
@@ -1053,16 +1104,84 @@ def currentMacAddresses(accessoryJson: str) -> dict[str, int] | None:
Returns None on failure so Java can decide how to recover - a missing or unreadable
accessory is worth telling apart from "no keys", which would be an empty mapping.
+
+ **An accessory whose window is absurdly wide is refused rather than derived**, and refused
+ here rather than in the caller, because by the time the caller could measure the answer the
+ work has already been done. See `_MAC_CANDIDATE_MAX_INDICES` for what that protects.
"""
try:
accessory = accessoryFromJson(accessoryJson)
+
+ now = datetime.now(timezone.utc)
+ width = _isAlignmentWide(
+ accessory, now - _MAC_CANDIDATE_MARGIN, now + _MAC_CANDIDATE_MARGIN)
+
+ if width > _MAC_CANDIDATE_MAX_INDICES:
+ # **Bounded to the newest slice rather than refused.** A tag advertising right now
+ # has been running, so its index tracks the wall clock and sits at the top of the
+ # window; the bottom belongs to a tag that was switched off for months, which is not
+ # advertising and so has nothing to match anyway. Refusing outright cost every
+ # never-aligned tag its BLE matching, which is a worse trade than searching the part
+ # of the range that can plausibly be live.
+ top = accessory.get_max_index(now + _MAC_CANDIDATE_MARGIN)
+ bottom = top - _MAC_CANDIDATE_MAX_INDICES
+ print(f"Candidate window is {width} indices wide; deriving only the newest "
+ f"{_MAC_CANDIDATE_MAX_INDICES} ({bottom}..{top}), which is what a running "
+ f"accessory can plausibly be advertising.")
+ return {
+ key.mac_address: index
+ for index, key in accessory.keys_between(max(0, bottom), top)
+ }
+
return accessory.current_mac_addresses(margin=_MAC_CANDIDATE_MARGIN)
except Exception:
print(f"currentMacAddresses failed: {traceback.format_exc()}")
return None
-def recordAccessorySeen(accessoryJson: str, mac: str, seenAtUnixMs: int) -> str | None:
+def _matchAt(accessory, mac: str, index: int | None):
+ """Check one index for `mac`, which is the whole point of the hint.
+
+ **Java knows which index its candidate set derived the address from, and cannot act on
+ it.** Only here can a primary key be told from a secondary one, and that distinction is what
+ decides whether alignment may be trusted. So the index arrives as a hint to be verified
+ rather than as an answer: this re-derives the keys at that index and checks the address
+ itself, exactly as the wide scan would, and reports the key type it actually found.
+
+ The saving is the reason it exists. The 48-hour window is around 1150 key derivations,
+ measured at 1.15s on desktop and several times that under Chaquopy; one index is three.
+ Running the wide version on the sighting callback's cadence put the app at 135% CPU with two
+ tags in range and got it killed for not answering input.
+ """
+ if index is None:
+ return None, None
+
+ for key in accessory.keys_at(index):
+ if key.mac_address != mac:
+ continue
+ if key.key_type == KeyPairType.PRIMARY:
+ return index, None
+ return None, index
+
+ return None, None
+
+
+def _matchAcross(candidates: dict, mac: str):
+ """Find `mac` among already-derived keys, preferring a primary match."""
+ matched_secondary = None
+
+ for key, index in candidates.items():
+ if key.mac_address != mac:
+ continue
+ if key.key_type == KeyPairType.PRIMARY:
+ return index, None
+ matched_secondary = index
+
+ return None, matched_secondary
+
+
+def recordAccessorySeen(accessoryJson: str, mac: str, seenAtUnixMs: int,
+ hintIndex: int | None = None) -> str | None:
"""
Tell an accessory it was seen advertising as `mac`, and hand back its new state.
@@ -1112,17 +1231,32 @@ def recordAccessorySeen(accessoryJson: str, mac: str, seenAtUnixMs: int) -> str
seen_at = datetime.fromtimestamp(seenAtUnixMs / 1000, tz=timezone.utc)
mac = mac.upper()
- matched_index = None
- for key, index in accessory.current_keys(seen_at, margin=_MAC_CANDIDATE_MARGIN).items():
- if key.key_type == KeyPairType.PRIMARY and key.mac_address == mac:
- matched_index = index
- break
+ matched_primary, matched_secondary = _matchAt(accessory, mac, hintIndex)
- if matched_index is None:
- return None
+ if matched_primary is None and matched_secondary is None:
+ # The hint missed, or there was none. Fall back to the window the caller's candidate
+ # set was built from - the address may belong to an index the hint did not name, and
+ # a scan that found it must not be thrown away over a wrong guess.
+ matched_primary, matched_secondary = _matchAcross(
+ accessory.current_keys(seen_at, margin=_MAC_CANDIDATE_MARGIN), mac)
mapping = json.loads(accessoryJson)
- if mapping.get("alignment_index") == matched_index:
+ stored_index = mapping.get("alignment_index")
+
+ if matched_primary is not None:
+ matched_index = matched_primary
+ elif matched_secondary is not None and (
+ stored_index is None or matched_secondary > stored_index):
+ # A floor, not a fix. The true index is somewhere in this secondary key's ~96-index
+ # span and the span cannot start below what the symmetric window reported, so moving
+ # alignment up to it can only ever undershoot the truth - never overshoot it, which
+ # is the direction that does damage. Raising the floor is what stops the lag growing
+ # without bound for a tag that only ever matches on its day key.
+ matched_index = matched_secondary
+ else:
+ return None
+
+ if stored_index == matched_index:
return None
# Bypassing update_alignment for the downward index move must not also bypass its
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java
index b9db1f05..f42109e1 100644
--- a/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java
@@ -63,7 +63,8 @@ public Map currentMacAddresses(final String accessoryJson) {
@Override
public String recordSeen(
- final String accessoryJson, final String mac, final long seenAtUnixMs) {
+ final String accessoryJson, final String mac, final long seenAtUnixMs,
+ final Integer hintIndex) {
this.sightings.add(mac);
return "{\"aligned\":true}";
}
@@ -123,6 +124,32 @@ public void noCandidateMacsShortCircuitsBeforeScanning() throws InterruptedExcep
assertEquals(BleSoundTriggerStatus.NO_CANDIDATE_MACS, items.get(0).getResult().getStatus());
}
+ /**
+ * The resolver's other way of saying it has no addresses: null, not an empty map.
+ *
+ * It answers that for an accessory it cannot read, and for one whose candidate window is
+ * too wide to be worth deriving - which is what an owner's own Apple device is, since a
+ * phone has no rolling-key alignment and reaches this code through "show my own Apple
+ * devices". Both mean the same thing to a ring attempt: there is nothing to scan for. Being
+ * dereferenced instead turned that into a thrown chain and an error toast.
+ */
+ @Test
+ public void aResolverThatRefusesIsTreatedAsNoCandidatesRatherThanThrowing()
+ throws InterruptedException {
+ final BleAccessorySoundTrigger trigger = new BleAccessorySoundTrigger<>(
+ new FakeResolver(null),
+ context -> true,
+ unreachableScanner(),
+ unreachableGattTrigger(),
+ s -> s,
+ 3, 0L, 0L);
+
+ final List items = playSoundBlocking(trigger);
+
+ assertEquals(1, items.size());
+ assertEquals(BleSoundTriggerStatus.NO_CANDIDATE_MACS, items.get(0).getResult().getStatus());
+ }
+
// --- the happy path ---------------------------------------------------------------------
@Test
diff --git a/app/src/test/python/test_main.py b/app/src/test/python/test_main.py
index 627f61f5..54df063f 100644
--- a/app/src/test/python/test_main.py
+++ b/app/src/test/python/test_main.py
@@ -206,6 +206,107 @@ def test_current_mac_addresses_refuses_an_unknown_accessory_type():
json.dumps({"type": "something_from_the_future"})) is None
+def _unaligned_accessory(paired_at: datetime) -> dict:
+ """An accessory that has never been aligned - what an owner's own Apple device looks like.
+
+ A phone reaches this code through "show my own Apple devices"; it has no rolling-key
+ alignment record and never gains one, so its candidate window spans its whole life.
+ """
+ from findmy import FindMyAccessory
+
+ accessory = FindMyAccessory(
+ master_key=b"\x11" * 28,
+ skn=b"\x22" * 32,
+ sks=b"\x33" * 32,
+ paired_at=paired_at,
+ name="Something with no alignment",
+ )
+ return accessory.to_json()
+
+
+def test_current_mac_addresses_bounds_a_window_too_wide_to_derive():
+ """Bounded rather than derived whole, and bounded *here*.
+
+ The caller asks per accessory in a loop, and the derivation is blocking. Measured on a real
+ device that had been switched off: 39636 indices, over a year of keys - the loop never
+ reached the tags after it, so the scan never started and every real tag silently stopped
+ being seen. A caller cannot protect itself from this, because by the time it could measure
+ the answer the work is already done.
+ """
+ stored = _unaligned_accessory(datetime.now(timezone.utc) - timedelta(days=400))
+
+ macs = main.currentMacAddresses(json.dumps(stored))
+
+ assert macs is not None and macs, "a never-aligned tag must still be scannable for"
+ spanned = max(macs.values()) - min(macs.values())
+ assert spanned <= main._MAC_CANDIDATE_MAX_INDICES, (
+ f"derived {spanned} indices, past the bound")
+
+
+def test_current_mac_addresses_bounds_to_the_newest_indices():
+ """The newest end, not the oldest.
+
+ An accessory advertising right now has been running, so its index tracks the wall clock.
+ The bottom of an over-wide window belongs to a tag that was off for months, which is not
+ advertising at all - deriving that end would spend the whole budget where nothing can match.
+ """
+ stored = _unaligned_accessory(datetime.now(timezone.utc) - timedelta(days=400))
+ accessory = main.accessoryFromJson(json.dumps(stored))
+ reachable_now = accessory.get_max_index(
+ datetime.now(timezone.utc) + main._MAC_CANDIDATE_MARGIN)
+
+ macs = main.currentMacAddresses(json.dumps(stored))
+
+ assert max(macs.values()) >= reachable_now - 1, (
+ "the newest reachable index must be inside the derived set")
+
+
+def _freshly_aligned_accessory() -> dict:
+ """An accessory aligned as of now, which is what any tag the network found looks like."""
+ from findmy import FindMyAccessory
+
+ now = datetime.now(timezone.utc)
+ accessory = FindMyAccessory(
+ master_key=b"\x11" * 28,
+ skn=b"\x22" * 32,
+ sks=b"\x33" * 32,
+ paired_at=now - timedelta(days=200),
+ name="A tag the network found this morning",
+ alignment_date=now,
+ alignment_index=19200,
+ )
+ return accessory.to_json()
+
+
+def test_current_mac_addresses_still_answers_for_a_freshly_aligned_accessory():
+ """The guard must not swallow the ordinary case it sits in front of.
+
+ Note this one is paired 200 days ago: it is the *alignment* being current that keeps the
+ window narrow, not the tag being new.
+ """
+ macs = main.currentMacAddresses(json.dumps(_freshly_aligned_accessory()))
+
+ assert macs is not None
+ assert len(macs) > 0
+
+
+def test_current_mac_addresses_bounds_an_accessory_whose_alignment_went_stale():
+ """Staleness is what sets the width, so an old alignment is bounded like none at all.
+
+ Deriving such a window whole took 9 seconds at 30 days stale and two minutes at 400 on a
+ desktop, per the table on `_MAC_CANDIDATE_MAX_INDICES` - several times that under Chaquopy.
+ The bound keeps the tag scannable without paying for the part of the range that cannot be
+ live.
+ """
+ stored = _paired_accessory(alignment_index=100) # aligned at a fixed date in the past
+
+ macs = main.currentMacAddresses(json.dumps(stored))
+
+ assert macs is not None and macs
+ spanned = max(macs.values()) - min(macs.values())
+ assert spanned <= main._MAC_CANDIDATE_MAX_INDICES
+
+
# --------------------------------------------------------------------------
# recordAccessorySeen
#
@@ -285,15 +386,110 @@ def test_recordAccessorySeen_is_a_noop_when_already_aligned():
assert main.recordAccessorySeen(json.dumps(stored), mac, _ms(_ALIGNMENT_DATE)) is None
-def test_recordAccessorySeen_ignores_a_secondary_match():
- """A secondary key is shared by 96 primary indices - realigning to its first match could
- move alignment in the wrong direction entirely, so only a primary match may correct it."""
+def test_recordAccessorySeen_with_a_hint_agrees_with_the_wide_search():
+ """The hint is an optimisation, not a second rule - both paths must answer the same.
+
+ Checking one index is three key derivations; the 48-hour window is about 1150, measured at
+ 1.15s on desktop and several times that under Chaquopy. Called on the sighting cadence
+ without the hint, the app sat at 135% CPU with two tags in range until Android killed it.
+ """
+ stored = _paired_accessory(alignment_index=2880)
+ true_index = 2850
+ mac = _mac_at(stored, true_index, KeyPairType.PRIMARY)
+ at = _ms(_ALIGNMENT_DATE)
+
+ without_hint = main.recordAccessorySeen(json.dumps(stored), mac, at)
+ with_hint = main.recordAccessorySeen(json.dumps(stored), mac, at, true_index)
+
+ assert with_hint == without_hint
+ assert json.loads(with_hint)["alignment_index"] == true_index
+
+
+def test_recordAccessorySeen_falls_back_when_the_hint_is_wrong():
+ """A hint that misses must cost the wide search, not the sighting.
+
+ The candidate set may have rolled between the scan that matched and this call, so the index
+ it named can be one the address no longer belongs to. Trusting the hint to be exhaustive
+ would silently drop a correction that was there to be made.
+ """
+ stored = _paired_accessory(alignment_index=2880)
+ true_index = 2850
+ mac = _mac_at(stored, true_index, KeyPairType.PRIMARY)
+
+ corrected = main.recordAccessorySeen(
+ json.dumps(stored), mac, _ms(_ALIGNMENT_DATE), true_index + 7)
+
+ assert corrected is not None
+ assert json.loads(corrected)["alignment_index"] == true_index
+
+
+def test_recordAccessorySeen_ignores_a_secondary_match_below_the_stored_alignment():
+ """A secondary key is shared by 96 primary indices, so its reported index is a floor rather
+ than a fix. A floor below where alignment already stands proves nothing and moves nothing."""
stored = _paired_accessory(alignment_index=2880)
mac = _mac_at(stored, 2850, KeyPairType.SECONDARY)
assert main.recordAccessorySeen(json.dumps(stored), mac, _ms(_ALIGNMENT_DATE)) is None
+def _a_secondary_reported_above(accessoryJson: dict, floor: int):
+ """A secondary key the app itself would report above `floor`, and the index it reports.
+
+ Picked through `current_keys` rather than by index arithmetic, because `keys_at` yields
+ more than one secondary key for a given index and taking whichever comes first says
+ nothing about where the app would place it. The window's own answer is the input the
+ function under test actually receives.
+ """
+ accessory = main.accessoryFromJson(json.dumps(accessoryJson))
+ reported = accessory.current_keys(_ALIGNMENT_DATE, margin=main._MAC_CANDIDATE_MARGIN)
+
+ for key, index in sorted(reported.items(), key=lambda pair: pair[1]):
+ if key.key_type == KeyPairType.SECONDARY and index > floor:
+ return key.mac_address, index
+
+ raise AssertionError(f"no secondary key is reported above index {floor}")
+
+
+def test_recordAccessorySeen_raises_the_floor_from_a_secondary_match_above_alignment():
+ """The case a long-separated tag actually presents, measured on hardware.
+
+ A tag that has been away from its owner holds a day key, so it matches on a secondary and
+ never on a primary - and with primary-only correction its alignment could never recover, no
+ matter how often somebody walked past it. Measured on a real accessory: heard at -24 dBm
+ lying beside the phone, its address sat 58 indices (14.5 hours) above where alignment
+ believed "now" was, and it was therefore absent from its own candidate set.
+
+ Raising alignment to the secondary's own index cannot overshoot: the true index lies inside
+ that key's ~96-index span, and the span starts at the index reported here.
+ """
+ stored = _paired_accessory(alignment_index=2880)
+ mac, reported_at = _a_secondary_reported_above(stored, 2880)
+
+ corrected = main.recordAccessorySeen(json.dumps(stored), mac, _ms(_ALIGNMENT_DATE))
+
+ assert corrected is not None
+ parsed = json.loads(corrected)
+ assert parsed["alignment_index"] == reported_at
+ assert parsed["alignment_index"] > 2880
+
+
+def test_recordAccessorySeen_never_moves_the_floor_past_the_key_that_justified_it():
+ """The floor may only ever undershoot the truth, which is what makes it safe to apply.
+
+ Overshooting is the failure that produced the 114-index drift this whole path exists to
+ undo, and it comes from taking the *highest* index a key could belong to. This takes the
+ lowest, so the stored index must never exceed the index whose key was actually heard.
+ """
+ stored = _paired_accessory(alignment_index=2880)
+ mac, reported_at = _a_secondary_reported_above(stored, 2880)
+
+ corrected = main.recordAccessorySeen(json.dumps(stored), mac, _ms(_ALIGNMENT_DATE))
+
+ # The span this key covers starts where it was reported, so the true index is at or above
+ # that. Storing anything higher would be inventing certainty the key does not carry.
+ assert json.loads(corrected)["alignment_index"] <= reported_at
+
+
def test_recordAccessorySeen_refuses_a_sighting_dated_before_the_stored_alignment():
"""The backward-time guard update_alignment has, kept when bypassing it: a rolled-back
device clock must not persist a (past date, current index) pair - the index extrapolated
From e88819f743307f859cec2d897beec4fd84b948c8 Mon Sep 17 00:00:00 2001
From: "Shane B."
Date: Wed, 26 Aug 2026 18:21:48 +0200
Subject: [PATCH 09/61] Say what the derivation now does, rather than what it
was going to do
Comment-only. Folding the two branches' thinking together left the prose
describing the design that was replaced, in five places, and one of them
is the number the margin is derived from.
`_MAC_CANDIDATE_MAX_INDICES` argued for bounding the range and closed
with "so it is deliberately not done in this commit", twenty lines above
the code that bounds it. Its heading, its closing paragraph and
`currentMacAddresses`' docstring all still said an over-wide window is
refused. It is not refused; it is cut to its newest thousand indices,
which is the better behaviour and the one worth describing.
`_MAC_CANDIDATE_MARGIN` still reasoned in the twenty-four hours it held
before, under a constant set to forty-eight. Restated with the 1150
derivations `_matchAt` already measures, so the two agree.
The floor's comment said a secondary key spans ~96 indices while the
margin above it derives 48 hours from 192. **192 is the right one** -
`_secondary_keys_at` returns `ind // 96 + 1` and `+ 2`, so each secondary
is reachable from two 96-blocks - and the margin depends on it, so the
stale 96 was the one that could mislead. Also written down there is why
the index is a lower bound at all: `keys_between` de-duplicates while
walking upward, so the index paired with a key is the lowest in the
range at which it is valid. That is what makes raising the floor safe,
and it was the one step of the argument the comment asserted without
saying where it comes from.
`AccessoryMacResolver.recordSeen` still promised null for "only a
secondary-key match", which is now exactly the case that writes.
flake8 clean, pyright unchanged (8 reportMissingImports before and
after, all from dependencies absent on this machine). Not built and no
suite run - there is no Android toolchain here.
Co-Authored-By: Claude Opus 5
---
.../python/AccessoryMacResolver.java | 4 +-
app/src/main/python/main.py | 46 +++++++++++--------
2 files changed, 31 insertions(+), 19 deletions(-)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java
index e884130a..2f0bdceb 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryMacResolver.java
@@ -55,7 +55,9 @@ public interface AccessoryMacResolver {
* when the caller does not know - the search then runs as it did before.
*
* @return the re-serialized accessory, or null if there was nothing worth recording - no
- * match, only a secondary-key match, or a failure. Null is not worth failing a caller over:
+ * match, a match that leaves alignment where it already is, or a failure. A secondary-key
+ * match does record, but only upward: it raises the alignment floor to a lower bound on the
+ * true index rather than setting the index itself, which only a primary match may do. Null is not worth failing a caller over:
* the sighting is an optimisation, and the sound either played or it did not regardless.
*
* Defaulted to "records nothing" rather than a second required method, so a
diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py
index e5dfb49b..1c40ecb0 100644
--- a/app/src/main/python/main.py
+++ b/app/src/main/python/main.py
@@ -1044,15 +1044,17 @@ def accessoryFromJson(accessoryJson: str) -> StoredAccessory:
# secondary-key floor is what pulls alignment back up afterwards, so the full width is only
# needed until the first sighting lands.
#
-# Twenty-four hours off a fresh alignment is on the order of two hundred keys, which is nothing. The
+# Forty-eight hours off a fresh alignment is around 1150 key derivations - 1.15s on desktop
+# and several times that under Chaquopy, which is why `recordAccessorySeen` takes an index
+# hint rather than re-deriving the window on every sighting. The
# cost is only bounded while the alignment *is* fresh, and this app does produce accessories
# where it is not: enabling "show my own Apple devices" puts a phone in the list, and a phone
# has no rolling-key alignment to be fresh. Measured on one that was switched off, the window
# came to 39636 indices - over a year of keys, derived on a blocking call. See
-# `_MAC_CANDIDATE_MAX_INDICES`, which is what stops that being attempted.
+# `_MAC_CANDIDATE_MAX_INDICES`, which is what stops the whole of that being attempted.
_MAC_CANDIDATE_MARGIN = timedelta(hours=48)
-#: How wide a candidate window may be before deriving it is refused outright.
+#: How much of a candidate window is derived when the whole of it is too wide.
#
# **This is a guard against one entry costing every other entry its scan.** Callers ask per
# accessory, in a loop, and the derivation is blocking EC work with no interruption point. If
@@ -1074,14 +1076,17 @@ def accessoryFromJson(accessoryJson: str) -> StoredAccessory:
# alignment and never gains one.
#
# A thousand is roughly a week of staleness: enough for a tag that has missed a few fetches,
-# and a couple of seconds at worst. An accessory past it is skipped rather than searched.
+# and a couple of seconds at worst.
#
-# **The better fix is to bound the range instead of refusing it**, taking the newest N indices
-# rather than the whole span. A tag that is advertising right now has been running, so its true
-# index tracks the wall clock and sits at the top of the window; the bottom is only reachable by
-# a tag that was switched off for months, which is not advertising and so has nothing to match
-# anyway. That wants a `max_indices` on `current_mac_addresses` in the pinned FindMy.py rather
-# than a second key walk here, so it is deliberately not done in this commit.
+# **An accessory past it is bounded rather than refused**, deriving the newest N indices rather
+# than the whole span. A tag that is advertising right now has been running, so its true index
+# tracks the wall clock and sits at the top of the window; the bottom is only reachable by a
+# tag that was switched off for months, which is not advertising and so has nothing to match
+# anyway. Refusing outright was the earlier answer and was worse: it cost every never-aligned
+# tag its BLE matching entirely.
+#
+# Done here with a second key walk, because `current_mac_addresses` in the pinned FindMy.py
+# has no `max_indices` to ask for this. Worth sending upstream so the walk can go.
_MAC_CANDIDATE_MAX_INDICES = 1000
@@ -1105,9 +1110,10 @@ def currentMacAddresses(accessoryJson: str) -> dict[str, int] | None:
Returns None on failure so Java can decide how to recover - a missing or unreadable
accessory is worth telling apart from "no keys", which would be an empty mapping.
- **An accessory whose window is absurdly wide is refused rather than derived**, and refused
- here rather than in the caller, because by the time the caller could measure the answer the
- work has already been done. See `_MAC_CANDIDATE_MAX_INDICES` for what that protects.
+ **An accessory whose window is absurdly wide has only its newest slice derived**, and that
+ is decided here rather than in the caller, because by the time the caller could measure the
+ answer the work has already been done. See `_MAC_CANDIDATE_MAX_INDICES` for what that
+ protects, and why the slice is the newest one.
"""
try:
accessory = accessoryFromJson(accessoryJson)
@@ -1247,11 +1253,15 @@ def recordAccessorySeen(accessoryJson: str, mac: str, seenAtUnixMs: int,
matched_index = matched_primary
elif matched_secondary is not None and (
stored_index is None or matched_secondary > stored_index):
- # A floor, not a fix. The true index is somewhere in this secondary key's ~96-index
- # span and the span cannot start below what the symmetric window reported, so moving
- # alignment up to it can only ever undershoot the truth - never overshoot it, which
- # is the direction that does damage. Raising the floor is what stops the lag growing
- # without bound for a tag that only ever matches on its day key.
+ # A floor, not a fix. The true index is somewhere in this secondary key's 192-index
+ # span - `keys_at` offers each secondary at both `ind // 96 + 1` and `+ 2`, so one is
+ # reachable from two 96-blocks, which is the same 192 the margin above is derived
+ # from - and `keys_between` de-duplicates while walking indices upward, so the index
+ # paired with a key is the lowest in the searched range at which it is valid. It is
+ # therefore a lower bound, and moving alignment up to it can only ever undershoot
+ # the truth - never overshoot it, which is the direction that does damage. Raising
+ # the floor is what stops the lag growing without bound for a tag that only ever
+ # matches on its day key.
matched_index = matched_secondary
else:
return None
From 0d6f9ec4224a82929dfdadfec789693a43dcc896 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 11:23:41 +0200
Subject: [PATCH 10/61] Hear the user's own tags while a screen is open, and
read their battery
Groundwork only: this adds the scanning and identification, with no UI yet.
FindMyAdvertisement parses Apple's offline-finding payload. The length byte
separates the two states (0x19 = separated from its owner, anything else =
owner nearby), which is how AirGuard reads it too, and the top two bits of
the status byte carry a coarse battery level, which is how FindMy.py reads
it. Every byte sequence in its test came off a real scan rather than out of
the spec.
NearbyTagIndex maps a scanned address back to a beacon for every tag at
once, and expires after ten minutes. Both halves matter: resolving per scan
result would start a Python interpreter per advertisement of anything at
all, and an index older than the fifteen minute key rollover silently stops
matching, which presents as "the tag is never nearby" rather than as a
failure.
NearbyTagWatcher ties scanning to a subscription rather than a service, so
the radio only runs while somebody is looking at the result. Recording
sightings for later is deliberately not in scope: that needs to run when
nobody is watching, and a locally-sourced position is a different claim from
one Apple's network made, which LocationReport cannot express today.
What a sighting can and cannot say is written down on NearbyTagSighting,
because it is asymmetric and easy to get wrong in UI: seeing a tag proves
presence, not seeing one proves nothing, since a tag with its owner present
does not advertise at all. That last part is measured, not assumed - a tag
sitting next to the phone with its owner's iPhone in the room did not appear
in a scan, and a blind connection attempt to the nearest owner-present
device timed out twice at Android's 30 second limit.
Verified: 210 JVM tests, 18 of them new. No hardware needed for any of them.
---
.../ble/FindMyAdvertisement.java | 102 +++++++++++
.../opentagviewer/ble/NearbyTagIndex.java | 96 ++++++++++
.../opentagviewer/ble/NearbyTagSighting.java | 38 ++++
.../opentagviewer/ble/NearbyTagWatcher.java | 172 ++++++++++++++++++
.../ble/FindMyAdvertisementTest.java | 99 ++++++++++
.../opentagviewer/ble/NearbyTagIndexTest.java | 138 ++++++++++++++
6 files changed, 645 insertions(+)
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisement.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSighting.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisementTest.java
create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexTest.java
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisement.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisement.java
new file mode 100644
index 00000000..35fc50f9
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisement.java
@@ -0,0 +1,102 @@
+package dev.wander.android.opentagviewer.ble;
+
+import androidx.annotation.Nullable;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * What an Apple Find My advertisement says about the accessory that sent it.
+ *
+ *
Pure parsing of the manufacturer payload, with no Android in it, so the rules below are
+ * covered by a JVM test rather than only by holding a tag and hoping.
+ *
+ *
Two payload shapes, and the difference is the whole point. An accessory separated
+ * from its owner broadcasts the full offline-finding beacon carrying its public key; one whose
+ * owner is present broadcasts a two-byte short form instead. The length byte is what tells them
+ * apart, which is also how AirGuard's {@code AppleFindMy.getConnectionState} reads it.
+ *
+ *
In practice only the separated form is useful here, and not because the short form is
+ * hard to parse. Measured on a real accessory: with its owner's phone in the room, the accessory
+ * did not appear in a scan at all, while every short-form advertisement seen came from something
+ * else nearby. That fits how the protocol works, since an accessory that holds a connection to
+ * its owner has no reason to advertise, and it is why a "not seen" result cannot be reported as
+ * "out of range" - see {@code NearbyTagSighting}.
+ */
+@AllArgsConstructor
+@Getter
+public final class FindMyAdvertisement {
+
+ /** Apple's Bluetooth SIG company identifier. */
+ public static final int APPLE_COMPANY_ID = 0x004C;
+
+ /** Apple's "offline finding" advertisement type, the first payload byte. */
+ private static final byte TYPE_OFFLINE_FINDING = 0x12;
+
+ /** Payload length of the full beacon an accessory sends once separated from its owner. */
+ private static final byte LEN_SEPARATED = 0x19;
+
+ /** Whether the sender is currently with its owner. */
+ public enum State {
+ /** Separated from its owner: broadcasting the full beacon, and reachable over GATT. */
+ SEPARATED,
+ /** Its owner is nearby. Recorded for completeness; see the class doc on why an
+ * accessory in this state is generally not seen at all. */
+ OWNER_NEARBY,
+ }
+
+ /**
+ * Battery level, from the top two bits of the status byte.
+ *
+ *
Same encoding FindMy.py reads (see its {@code BATTERY_LEVEL} map). Coarse by design:
+ * the protocol carries four levels, not a percentage.
+ */
+ public enum BatteryLevel {
+ FULL,
+ MEDIUM,
+ LOW,
+ VERY_LOW,
+ }
+
+ private final State state;
+ private final BatteryLevel batteryLevel;
+
+ /** The raw status byte, kept so a bug report can quote it rather than only our reading. */
+ private final int statusByte;
+
+ /**
+ * Parses Apple manufacturer data, or returns null when it is not a Find My advertisement.
+ *
+ * @param appleManufacturerData the payload for {@link #APPLE_COMPANY_ID}, as returned by
+ * {@code ScanRecord.getManufacturerSpecificData}. Null-safe:
+ * most devices in any scan carry no Apple data at all.
+ */
+ @Nullable
+ public static FindMyAdvertisement parse(@Nullable final byte[] appleManufacturerData) {
+ // Three bytes minimum: type, length, status. The short form is exactly this long, so
+ // anything below it cannot be read even to establish the state.
+ if (appleManufacturerData == null || appleManufacturerData.length < 3) {
+ return null;
+ }
+ if (appleManufacturerData[0] != TYPE_OFFLINE_FINDING) {
+ return null;
+ }
+
+ final State state = appleManufacturerData[1] == LEN_SEPARATED
+ ? State.SEPARATED
+ : State.OWNER_NEARBY;
+
+ final int status = appleManufacturerData[2] & 0xFF;
+ return new FindMyAdvertisement(state, batteryLevelOf(status), status);
+ }
+
+ private static BatteryLevel batteryLevelOf(final int statusByte) {
+ switch ((statusByte >> 6) & 0b11) {
+ case 0b01: return BatteryLevel.MEDIUM;
+ case 0b10: return BatteryLevel.LOW;
+ case 0b11: return BatteryLevel.VERY_LOW;
+ case 0b00:
+ default: return BatteryLevel.FULL;
+ }
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java
new file mode 100644
index 00000000..14f1e584
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java
@@ -0,0 +1,96 @@
+package dev.wander.android.opentagviewer.ble;
+
+import androidx.annotation.Nullable;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import dev.wander.android.opentagviewer.python.AccessoryMacResolver;
+
+/**
+ * Which beacon a scanned BLE address belongs to, for every tag at once.
+ *
+ *
Why this exists rather than resolving per scan result.
+ * {@code AccessoryMacResolver.currentMacAddresses} starts a Python interpreter and runs an EC
+ * derivation. A scan in an ordinary flat produces tens of results per second, and a screen left
+ * open produces them for as long as it is open, so resolving per result would be one interpreter
+ * start per advertisement of anything, ours or not. Resolving once for every tag and matching
+ * against a map turns that into a hash lookup.
+ *
+ *
Why it expires. The addresses are rolling keys: an accessory moves to the next one
+ * roughly every 15 minutes, and a fetch that updates a tag's alignment changes which addresses
+ * are predicted at all. A map built once and kept would quietly stop matching, which presents as
+ * "the tag is never nearby" rather than as anything failing.
+ *
+ *
No Android and no Bluetooth in here, so the expiry rule and the matching are covered by a
+ * JVM test; the clock is a parameter for the same reason.
+ */
+public final class NearbyTagIndex {
+
+ /**
+ * How long a built index is trusted.
+ *
+ *
Under the 15 minute rollover interval on purpose. Rebuilding slightly too often costs
+ * one Python call per tag; rebuilding too late costs sightings, and a missed sighting is
+ * indistinguishable from an absent tag.
+ */
+ static final long MAX_AGE_MS = TimeUnit.MINUTES.toMillis(10);
+
+ private final Map beaconIdByMac = new HashMap<>();
+ private long builtAtMs = Long.MIN_VALUE;
+
+ /** True when this has never been built, or was built long enough ago to be doubted. */
+ public boolean isStale(final long nowMs) {
+ return this.builtAtMs == Long.MIN_VALUE || nowMs - this.builtAtMs >= MAX_AGE_MS;
+ }
+
+ /**
+ * Resolves every tag's current candidate addresses and replaces the index with them.
+ *
+ * Blocking, once per tag. Call it off the main thread.
+ *
+ * @param accessoryJsonByBeaconId the persisted accessory JSON per beacon. An entry whose
+ * JSON is null or unreadable is skipped rather than failing
+ * the rebuild: a tag that has not been backfilled yet should
+ * cost only its own sightings, not everyone else's.
+ */
+ public void rebuild(
+ final Map accessoryJsonByBeaconId,
+ final AccessoryMacResolver resolver,
+ final long nowMs) {
+ final Map rebuilt = new HashMap<>();
+
+ for (final Map.Entry entry : accessoryJsonByBeaconId.entrySet()) {
+ final List macs = resolver.currentMacAddresses(entry.getValue());
+ for (final String mac : macs) {
+ if (mac != null) {
+ // Upper-cased on the way in so lookups need no normalisation per scan
+ // result, which is the hot path. Android reports uppercase and FindMy.py
+ // produces uppercase, but neither promises it forever.
+ rebuilt.put(mac.toUpperCase(Locale.ROOT), entry.getKey());
+ }
+ }
+ }
+
+ this.beaconIdByMac.clear();
+ this.beaconIdByMac.putAll(rebuilt);
+ this.builtAtMs = nowMs;
+ }
+
+ /** The beacon this address belongs to, or null if it is not one of ours. */
+ @Nullable
+ public String beaconIdFor(@Nullable final String scannedAddress) {
+ if (scannedAddress == null) {
+ return null;
+ }
+ return this.beaconIdByMac.get(scannedAddress.toUpperCase(Locale.ROOT));
+ }
+
+ /** How many addresses are currently being watched for, across all tags. For logging. */
+ public int size() {
+ return this.beaconIdByMac.size();
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSighting.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSighting.java
new file mode 100644
index 00000000..1698c2cb
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSighting.java
@@ -0,0 +1,38 @@
+package dev.wander.android.opentagviewer.ble;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * One of the user's own tags, seen by this phone's radio just now.
+ *
+ * A sighting is a positive claim only, and the absence of one claims nothing. Seeing a
+ * tag proves it was in range at that instant and reports what its own beacon said about its
+ * battery. Not seeing it means any of: out of range, with its owner and therefore not
+ * advertising at all (measured, see {@link FindMyAdvertisement}), or simply silent during the
+ * window. Those are indistinguishable from here, so nothing may present "no sighting" as "out of
+ * range" - the honest rendering is to show what was seen and stay quiet otherwise.
+ *
+ *
The battery level is the reason this is worth surfacing at all. The value the app has
+ * otherwise comes from the iCloud record, which only Apple devices ever refresh, so for a tag
+ * imported from a file it is whatever was true when the export was made - possibly years ago,
+ * which is why it sits behind the debug switch. This one comes from the tag itself, in the
+ * moment it was heard.
+ */
+@AllArgsConstructor
+@Getter
+public final class NearbyTagSighting {
+
+ private final String beaconId;
+
+ /** Signal strength in dBm. Negative; closer to zero is nearer. */
+ private final int rssi;
+
+ private final FindMyAdvertisement.BatteryLevel batteryLevel;
+
+ /** Whether the beacon said it was separated from its owner. See {@link FindMyAdvertisement}. */
+ private final FindMyAdvertisement.State state;
+
+ /** Wall-clock time of the sighting, so a stale one can be aged out rather than left on screen. */
+ private final long seenAtMs;
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
new file mode 100644
index 00000000..a6b304b9
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
@@ -0,0 +1,172 @@
+package dev.wander.android.opentagviewer.ble;
+
+import android.annotation.SuppressLint;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanRecord;
+import android.bluetooth.le.ScanResult;
+import android.bluetooth.le.ScanSettings;
+import android.content.Context;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import java.util.Map;
+
+import dev.wander.android.opentagviewer.python.AccessoryMacResolver;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
+/**
+ * Reports the user's own tags as this phone hears them, for as long as somebody is subscribed.
+ *
+ *
Scanning is tied to a screen being open, not to a service. Nothing here runs in the
+ * background: the caller subscribes in {@code onResume} and disposes in {@code onPause}, so the
+ * radio is only on while a person is actually looking at the result. That keeps this a display
+ * feature rather than a tracking one - no foreground service, no ongoing notification, and a
+ * scan alongside a lit screen costs little next to the screen itself.
+ *
+ *
Recording sightings for later, which is the other obvious thing to do with a scan, is
+ * deliberately not this class's job. That is a different feature with different consequences:
+ * it needs to run when nobody is watching, and a locally-sourced position is a different claim
+ * from one Apple's network made, which the location history has no way to express today.
+ *
+ *
{@code SCAN_MODE_LOW_POWER} rather than the low-latency mode
+ * {@link NearbyAccessoryScanner} uses. That one runs for a few seconds after an explicit tap and
+ * wants an answer now; this one runs for as long as a screen is open and only needs to notice a
+ * tag within a few seconds.
+ */
+public class NearbyTagWatcher {
+ private static final String TAG = NearbyTagWatcher.class.getSimpleName();
+
+ /** Injectable so a test can drive the whole pipeline without a radio. */
+ interface Clock {
+ long nowMs();
+ }
+
+ private final AccessoryMacResolver macResolver;
+ private final NearbyTagIndex index;
+ private final Clock clock;
+
+ public NearbyTagWatcher(final AccessoryMacResolver macResolver) {
+ this(macResolver, new NearbyTagIndex(), System::currentTimeMillis);
+ }
+
+ NearbyTagWatcher(final AccessoryMacResolver macResolver, final NearbyTagIndex index,
+ final Clock clock) {
+ this.macResolver = macResolver;
+ this.index = index;
+ this.clock = clock;
+ }
+
+ /**
+ * Emits a {@link NearbyTagSighting} every time one of the given tags is heard.
+ *
+ *
Emits repeatedly for the same tag, once per advertisement, rather than once per tag:
+ * the caller wants a live signal strength and a fresh timestamp, not a one-off announcement.
+ *
+ *
Never errors on an ordinary failure. Missing permission or a Bluetooth adapter that is
+ * off simply produce no sightings, because there is nothing for a caller to do about either
+ * beyond what it already does for the ring button, and a screen must not break because the
+ * radio is off.
+ *
+ * @param accessoryJsonByBeaconId the persisted accessory JSON per beacon, for the tags worth
+ * watching for.
+ */
+ @SuppressLint("MissingPermission")
+ public Observable watch(
+ final Context context, final Map accessoryJsonByBeaconId) {
+ return Observable.create(emitter -> {
+ if (!BlePermissions.granted(context)) {
+ Log.d(TAG, "Not watching for nearby tags: BLE permission not granted");
+ emitter.onComplete();
+ return;
+ }
+ if (accessoryJsonByBeaconId.isEmpty()) {
+ emitter.onComplete();
+ return;
+ }
+
+ // Blocking, one interpreter start per tag - hence subscribeOn(io) below, and hence
+ // the index rather than resolving per scan result. See NearbyTagIndex.
+ if (this.index.isStale(this.clock.nowMs())) {
+ this.index.rebuild(accessoryJsonByBeaconId, this.macResolver, this.clock.nowMs());
+ Log.d(TAG, "Watching " + this.index.size() + " candidate address(es) for "
+ + accessoryJsonByBeaconId.size() + " tag(s)");
+ }
+
+ final BluetoothManager manager =
+ (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
+ final BluetoothAdapter adapter = manager == null ? null : manager.getAdapter();
+ final BluetoothLeScanner scanner =
+ adapter == null ? null : adapter.getBluetoothLeScanner();
+ if (scanner == null) {
+ Log.d(TAG, "Not watching for nearby tags: Bluetooth is off or unsupported");
+ emitter.onComplete();
+ return;
+ }
+
+ final ScanCallback callback = new ScanCallback() {
+ @Override
+ public void onScanResult(final int callbackType, final ScanResult result) {
+ final NearbyTagSighting sighting = sightingFrom(result);
+ if (sighting != null && !emitter.isDisposed()) {
+ emitter.onNext(sighting);
+ }
+ }
+
+ @Override
+ public void onScanFailed(final int errorCode) {
+ // Not an error onto the subscriber: see the method contract. A screen that
+ // cannot scan shows no badges, which is the same as seeing nothing.
+ Log.w(TAG, "Nearby tag scan failed (errorCode=" + errorCode + ")");
+ if (!emitter.isDisposed()) {
+ emitter.onComplete();
+ }
+ }
+ };
+
+ scanner.startScan(null,
+ new ScanSettings.Builder()
+ .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
+ .build(),
+ callback);
+
+ emitter.setCancellable(() -> {
+ Log.d(TAG, "Stopped watching for nearby tags");
+ scanner.stopScan(callback);
+ });
+ }).subscribeOn(Schedulers.io());
+ }
+
+ /**
+ * One scan result turned into a sighting, or null if it is not one of ours.
+ *
+ * Package-private and separated from the scan callback so the decision - is this Find My
+ * at all, is it a tag we own, what did it say - is reachable by a test without a radio.
+ */
+ @Nullable
+ NearbyTagSighting sightingFrom(final ScanResult result) {
+ final ScanRecord record = result.getScanRecord();
+ if (record == null) {
+ return null;
+ }
+
+ final FindMyAdvertisement advertisement = FindMyAdvertisement.parse(
+ record.getManufacturerSpecificData(FindMyAdvertisement.APPLE_COMPANY_ID));
+ if (advertisement == null) {
+ return null;
+ }
+
+ // Most Find My advertisements in any scan belong to strangers; only ours resolve.
+ final String beaconId = this.index.beaconIdFor(result.getDevice().getAddress());
+ if (beaconId == null) {
+ return null;
+ }
+
+ return new NearbyTagSighting(beaconId, result.getRssi(), advertisement.getBatteryLevel(),
+ advertisement.getState(), this.clock.nowMs());
+ }
+}
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisementTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisementTest.java
new file mode 100644
index 00000000..122002d6
--- /dev/null
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisementTest.java
@@ -0,0 +1,99 @@
+package dev.wander.android.opentagviewer.ble;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import org.junit.Test;
+
+import dev.wander.android.opentagviewer.ble.FindMyAdvertisement.BatteryLevel;
+import dev.wander.android.opentagviewer.ble.FindMyAdvertisement.State;
+
+/**
+ * The payload rules, pinned against real captures.
+ *
+ *
Every byte sequence below was observed in an actual scan on a Pixel 10 Pro, rather than
+ * constructed from the spec, so a change here fails against what accessories really send.
+ */
+public class FindMyAdvertisementTest {
+
+ /** A separated accessory, battery full. Captured from the author's own tag. */
+ private static final byte[] SEPARATED_FULL = {0x12, 0x19, 0x20};
+
+ /** A separated accessory reporting a low battery. Captured from a stranger's tag nearby. */
+ private static final byte[] SEPARATED_LOW = {0x12, 0x19, (byte) 0x90};
+
+ /** The short form, sent while the owner is present. Captured repeatedly. */
+ private static final byte[] OWNER_NEARBY = {0x12, 0x02, 0x00};
+
+ @Test
+ public void readsTheSeparatedState() {
+ assertEquals(State.SEPARATED, FindMyAdvertisement.parse(SEPARATED_FULL).getState());
+ }
+
+ @Test
+ public void readsTheOwnerNearbyState() {
+ assertEquals(State.OWNER_NEARBY, FindMyAdvertisement.parse(OWNER_NEARBY).getState());
+ }
+
+ /**
+ * Only 0x19 means separated. Anything else is the short form, which is how AirGuard
+ * reads it too. Pinned because treating an unknown length as "separated" would have us
+ * announce a tag as reachable when it is not.
+ */
+ @Test
+ public void anyLengthOtherThanTheFullBeaconCountsAsOwnerNearby() {
+ assertEquals(State.OWNER_NEARBY,
+ FindMyAdvertisement.parse(new byte[] {0x12, 0x0A, 0x00}).getState());
+ }
+
+ @Test
+ public void readsTheBatteryLevelFromTheTopTwoBits() {
+ assertEquals(BatteryLevel.FULL, FindMyAdvertisement.parse(SEPARATED_FULL).getBatteryLevel());
+ assertEquals(BatteryLevel.LOW, FindMyAdvertisement.parse(SEPARATED_LOW).getBatteryLevel());
+ }
+
+ @Test
+ public void coversAllFourBatteryLevels() {
+ assertEquals(BatteryLevel.FULL,
+ FindMyAdvertisement.parse(new byte[] {0x12, 0x19, 0x00}).getBatteryLevel());
+ assertEquals(BatteryLevel.MEDIUM,
+ FindMyAdvertisement.parse(new byte[] {0x12, 0x19, 0x40}).getBatteryLevel());
+ assertEquals(BatteryLevel.LOW,
+ FindMyAdvertisement.parse(new byte[] {0x12, 0x19, (byte) 0x80}).getBatteryLevel());
+ assertEquals(BatteryLevel.VERY_LOW,
+ FindMyAdvertisement.parse(new byte[] {0x12, 0x19, (byte) 0xC0}).getBatteryLevel());
+ }
+
+ /**
+ * The status byte is kept raw as well as interpreted. A bug report quoting 0x90 is
+ * answerable; one quoting "Low" is not, if the reading itself is what is wrong.
+ */
+ @Test
+ public void keepsTheRawStatusByteUnsigned() {
+ assertEquals(0x90, FindMyAdvertisement.parse(SEPARATED_LOW).getStatusByte());
+ }
+
+ // --- what is not a Find My advertisement -------------------------------------------------
+
+ @Test
+ public void ignoresDevicesWithNoAppleData() {
+ assertNull(FindMyAdvertisement.parse(null));
+ }
+
+ /**
+ * Apple broadcasts plenty of other types - handoff, nearby-info, and so on. Captured
+ * examples: 0x10, 0x0F, 0x13, 0x09. None of them are ours.
+ */
+ @Test
+ public void ignoresOtherAppleAdvertisementTypes() {
+ assertNull(FindMyAdvertisement.parse(new byte[] {0x10, 0x05, 0x03}));
+ assertNull(FindMyAdvertisement.parse(new byte[] {0x0F, 0x05, (byte) 0x90}));
+ assertNull(FindMyAdvertisement.parse(new byte[] {0x13, 0x08, 0x4A}));
+ }
+
+ @Test
+ public void ignoresAPayloadTooShortToRead() {
+ assertNull(FindMyAdvertisement.parse(new byte[] {0x12, 0x19}));
+ assertNull(FindMyAdvertisement.parse(new byte[] {}));
+ }
+}
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexTest.java
new file mode 100644
index 00000000..ef1fb508
--- /dev/null
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexTest.java
@@ -0,0 +1,138 @@
+package dev.wander.android.opentagviewer.ble;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import dev.wander.android.opentagviewer.python.AccessoryMacResolver;
+
+/** A JVM test: {@link NearbyTagIndex} has no Android and no Bluetooth in it, deliberately. */
+public class NearbyTagIndexTest {
+
+ private static final String KEYS = "keys-beacon-id";
+ private static final String BIKE = "bike-beacon-id";
+
+ private static Map twoTags() {
+ final Map tags = new HashMap<>();
+ tags.put(KEYS, "{\"type\":\"accessory\",\"tag\":\"keys\"}");
+ tags.put(BIKE, "{\"type\":\"accessory\",\"tag\":\"bike\"}");
+ return tags;
+ }
+
+ /** Answers a different address set per accessory, so a mix-up between tags would show. */
+ private static AccessoryMacResolver resolverFor(final Map> byJson) {
+ return json -> byJson.getOrDefault(json, List.of());
+ }
+
+ @Test
+ public void mapsEveryCandidateAddressBackToItsTag() {
+ final Map> answers = new HashMap<>();
+ answers.put("{\"type\":\"accessory\",\"tag\":\"keys\"}",
+ List.of("AA:AA:AA:AA:AA:01", "AA:AA:AA:AA:AA:02"));
+ answers.put("{\"type\":\"accessory\",\"tag\":\"bike\"}",
+ List.of("BB:BB:BB:BB:BB:01"));
+
+ final NearbyTagIndex index = new NearbyTagIndex();
+ index.rebuild(twoTags(), resolverFor(answers), 0L);
+
+ assertEquals(3, index.size());
+ assertEquals(KEYS, index.beaconIdFor("AA:AA:AA:AA:AA:01"));
+ assertEquals(KEYS, index.beaconIdFor("AA:AA:AA:AA:AA:02"));
+ assertEquals(BIKE, index.beaconIdFor("BB:BB:BB:BB:BB:01"));
+ }
+
+ @Test
+ public void anAddressThatIsNotOursResolvesToNothing() {
+ final NearbyTagIndex index = new NearbyTagIndex();
+ index.rebuild(twoTags(), resolverFor(Map.of()), 0L);
+
+ assertNull(index.beaconIdFor("CC:CC:CC:CC:CC:CC"));
+ assertNull(index.beaconIdFor(null));
+ }
+
+ /** Neither side promises a casing forever, and a casing mismatch would present as
+ * "the tag is never nearby" rather than as anything failing. */
+ @Test
+ public void matchingIgnoresCase() {
+ final NearbyTagIndex index = new NearbyTagIndex();
+ index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of("j", List.of("aa:bb:cc:dd:ee:ff"))), 0L);
+
+ assertEquals(KEYS, index.beaconIdFor("AA:BB:CC:DD:EE:FF"));
+ assertEquals(KEYS, index.beaconIdFor("aa:bb:cc:dd:ee:ff"));
+ }
+
+ // --- expiry -------------------------------------------------------------------------------
+
+ @Test
+ public void aFreshlyConstructedIndexIsStale() {
+ assertTrue(new NearbyTagIndex().isStale(0L));
+ }
+
+ @Test
+ public void staysFreshInsideTheWindowAndExpiresAtIt() {
+ final NearbyTagIndex index = new NearbyTagIndex();
+ index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of()), 1_000L);
+
+ assertFalse(index.isStale(1_000L));
+ assertFalse(index.isStale(1_000L + NearbyTagIndex.MAX_AGE_MS - 1));
+ assertTrue("must expire before the 15 minute rollover, or sightings are missed",
+ index.isStale(1_000L + NearbyTagIndex.MAX_AGE_MS));
+ }
+
+ @Test
+ public void expiryIsShorterThanTheRolloverInterval() {
+ assertTrue("an index older than a rollover predicts addresses nothing is sending any more",
+ NearbyTagIndex.MAX_AGE_MS < java.util.concurrent.TimeUnit.MINUTES.toMillis(15));
+ }
+
+ // --- rebuilding ---------------------------------------------------------------------------
+
+ @Test
+ public void rebuildingReplacesTheOldAddressesRatherThanAccumulating() {
+ final NearbyTagIndex index = new NearbyTagIndex();
+ index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of("j", List.of("AA:AA:AA:AA:AA:01"))), 0L);
+ index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of("j", List.of("AA:AA:AA:AA:AA:99"))), 1L);
+
+ assertEquals(1, index.size());
+ assertNull("a rolled-past address must stop matching", index.beaconIdFor("AA:AA:AA:AA:AA:01"));
+ assertEquals(KEYS, index.beaconIdFor("AA:AA:AA:AA:AA:99"));
+ }
+
+ /**
+ * A tag whose accessory JSON has not been backfilled yet resolves to nothing. It must cost
+ * only its own sightings, not the whole rebuild.
+ */
+ @Test
+ public void oneUnresolvableTagDoesNotCostTheOthers() {
+ final Map tags = new HashMap<>();
+ tags.put(KEYS, "good");
+ tags.put(BIKE, "unbackfilled");
+
+ final NearbyTagIndex index = new NearbyTagIndex();
+ index.rebuild(tags, resolverFor(Map.of("good", List.of("AA:AA:AA:AA:AA:01"))), 0L);
+
+ assertEquals(KEYS, index.beaconIdFor("AA:AA:AA:AA:AA:01"));
+ assertEquals(1, index.size());
+ }
+
+ @Test
+ public void resolvesEachTagExactlyOncePerRebuild() {
+ final AtomicInteger calls = new AtomicInteger();
+ final AccessoryMacResolver counting = json -> {
+ calls.incrementAndGet();
+ return List.of();
+ };
+
+ new NearbyTagIndex().rebuild(twoTags(), counting, 0L);
+
+ assertEquals("one interpreter start per tag, not per address", 2, calls.get());
+ }
+}
From 7bb30c9f5bab6f42b45ace70a80ff5178813d8a3 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 11:39:42 +0200
Subject: [PATCH 11/61] Say when a tag is audible, and show the battery it
reports itself
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Builds on the scanning groundwork: the map's tag card swaps its "last
updated" line for "Nearby · Battery full" while the tag can be heard, and
the device screen grows a battery row outside the debug panel.
The card replaces rather than adds a line. The two say different things and
the newer one wins - "last updated two hours ago" is when Apple's network
last reported it, a sighting is this phone hearing it now - and the row is
measured to the pixel, so a second line would cost height the cards do not
have.
The device screen scans for its own tag rather than being handed a sighting
from the map, because opening it pauses MapsActivity and stops that scan, so
anything passed across would be stale on arrival - and this screen can be
reached without the map having run at all.
The new battery row never falls back to the iCloud value. That value already
has a row, in the debug panel, with a caveat saying only Apple devices ever
refresh it; a tag imported from a file keeps whatever was true when the
export was made. A live reading is worth showing to everybody precisely
because it cannot be stale, so filling it from the record that can be would
defeat the point. Hidden when the tag has not been heard, rather than
showing a number with no way to tell which kind it is.
Both surfaces fall back on their own: a sighting ages out after thirty
seconds, because nothing announces that a tag has left - we simply stop
hearing it.
Verified: 217 JVM tests (7 new), 8 new instrumented layout tests, 17 tag
card tests still green, 283 strings across 10 locales. The layout tests were
checked against a deliberately broken layout first: making the row visible
by default turns exactly the two that assert otherwise red.
---
.../ui/DeviceInfoLiveBatteryLayoutTest.java | 163 ++++++++++++++++++
.../opentagviewer/DeviceInfoActivity.java | 71 ++++++++
.../android/opentagviewer/MapsActivity.java | 117 ++++++++++++-
.../opentagviewer/ble/NearbyTagLabel.java | 36 ++++
.../opentagviewer/ble/NearbyTagSightings.java | 56 ++++++
.../main/res/layout/activity_device_info.xml | 27 +++
app/src/main/res/values-de/strings.xml | 8 +
app/src/main/res/values-en/strings.xml | 8 +
app/src/main/res/values-fr/strings.xml | 8 +
app/src/main/res/values-ja/strings.xml | 8 +
app/src/main/res/values-ko/strings.xml | 8 +
app/src/main/res/values-nl/strings.xml | 8 +
app/src/main/res/values-ru/strings.xml | 8 +
app/src/main/res/values-zh-rCN/strings.xml | 8 +
app/src/main/res/values-zh-rTW/strings.xml | 8 +
app/src/main/res/values/strings.xml | 8 +
.../ble/NearbyTagSightingsTest.java | 89 ++++++++++
17 files changed, 631 insertions(+), 8 deletions(-)
create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/ui/DeviceInfoLiveBatteryLayoutTest.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSightings.java
create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagSightingsTest.java
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/DeviceInfoLiveBatteryLayoutTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/DeviceInfoLiveBatteryLayoutTest.java
new file mode 100644
index 00000000..aa539563
--- /dev/null
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/DeviceInfoLiveBatteryLayoutTest.java
@@ -0,0 +1,163 @@
+package dev.wander.android.opentagviewer.ui;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.appcompat.view.ContextThemeWrapper;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import dev.wander.android.opentagviewer.R;
+
+/**
+ * The live battery row on the device screen: the one fed by hearing the tag itself over
+ * Bluetooth, rather than by the iCloud record.
+ *
+ * Hidden is its resting state, and that is the part worth pinning. It is only filled
+ * in once the tag has actually been heard, and it deliberately never falls back to the iCloud
+ * value - the whole reason it sits outside the debug panel is that it cannot be stale. A stray
+ * edit making it visible by default would put an empty row on every device screen; one wiring it
+ * to {@code batteryLevel} would silently reintroduce the staleness it exists to avoid.
+ *
+ *
Inflation only: no activity, no account, no Bluetooth. Run with
+ * {@code ./gradlew :app:testEmulatorDebugAndroidTest}.
+ */
+@RunWith(AndroidJUnit4.class)
+public class DeviceInfoLiveBatteryLayoutTest {
+
+ private static final int SCREEN_WIDTH_PX = 1080;
+
+ private Context context;
+
+ @Before
+ public void setUp() {
+ this.context = new ContextThemeWrapper(
+ getInstrumentation().getTargetContext(), R.style.Theme_OpenTagViewer);
+ }
+
+ private View inflateDeviceInfo() {
+ final View[] root = new View[1];
+ getInstrumentation().runOnMainSync(() ->
+ root[0] = LayoutInflater.from(this.context)
+ .inflate(R.layout.activity_device_info, null));
+ return root[0];
+ }
+
+ @Test
+ public void theScreenStillInflates() {
+ assertNotNull(this.inflateDeviceInfo());
+ }
+
+ /** The id {@code DeviceInfoActivity.showLiveBattery} looks up has to resolve, or the row is
+ * simply never shown and nothing fails. */
+ @Test
+ public void theLiveBatteryRowExists() {
+ assertNotNull(this.inflateDeviceInfo().findViewById(R.id.device_settings_live_battery));
+ }
+
+ @Test
+ public void theLiveBatteryRowIsHiddenUntilTheTagIsHeard() {
+ final View row = this.inflateDeviceInfo().findViewById(R.id.device_settings_live_battery);
+ assertEquals("an unheard tag must show no battery row at all, not an empty one",
+ View.GONE, row.getVisibility());
+ }
+
+ /** Shown, it has to occupy real space rather than measuring to nothing. */
+ @Test
+ public void theLiveBatteryRowHasRealSizeOnceShown() {
+ final int[] size = new int[2];
+
+ getInstrumentation().runOnMainSync(() -> {
+ final View screen = LayoutInflater.from(this.context)
+ .inflate(R.layout.activity_device_info, null);
+ final View row = screen.findViewById(R.id.device_settings_live_battery);
+ row.setVisibility(View.VISIBLE);
+
+ screen.measure(
+ View.MeasureSpec.makeMeasureSpec(SCREEN_WIDTH_PX, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(2400, View.MeasureSpec.EXACTLY));
+ screen.layout(0, 0, SCREEN_WIDTH_PX, 2400);
+
+ size[0] = row.getMeasuredWidth();
+ size[1] = row.getMeasuredHeight();
+ });
+
+ assertTrue("row measured " + size[0] + "x" + size[1], size[0] > 0 && size[1] > 0);
+ }
+
+ /**
+ * It sits outside the debug panel, which is the whole point. Inside it, the reading
+ * would be invisible to everyone who has not turned debug data on, and this row exists
+ * because a live reading is worth showing to everybody.
+ */
+ @Test
+ public void theLiveBatteryRowIsNotInsideTheDebugPanel() {
+ final View screen = this.inflateDeviceInfo();
+ final View debugPanel = screen.findViewById(R.id.device_debug_info);
+ final View row = screen.findViewById(R.id.device_settings_live_battery);
+
+ assertNotNull(debugPanel);
+ assertTrue("the live reading must not be gated behind the debug switch",
+ ((ViewGroup) debugPanel).findViewById(R.id.device_settings_live_battery) == null);
+ assertNotNull(row);
+ }
+
+ /** Half of what breaks only breaks in one mode. */
+ @Test
+ public void theRowSurvivesDarkMode() {
+ final Configuration night = new Configuration(
+ this.context.getResources().getConfiguration());
+ night.uiMode = Configuration.UI_MODE_NIGHT_YES | Configuration.UI_MODE_TYPE_NORMAL;
+
+ final Context darkContext = new ContextThemeWrapper(
+ this.context.createConfigurationContext(night), R.style.Theme_OpenTagViewer);
+
+ final View[] row = new View[1];
+ getInstrumentation().runOnMainSync(() -> row[0] = LayoutInflater.from(darkContext)
+ .inflate(R.layout.activity_device_info, null)
+ .findViewById(R.id.device_settings_live_battery));
+
+ assertNotNull(row[0]);
+ assertEquals(View.GONE, row[0].getVisibility());
+ }
+
+ /**
+ * The short battery words are what the row and the tag card show. The debug panel's own
+ * strings spell out percentage ranges and a caveat, which is right there and far too long
+ * for a one-line row - so this pins that they stayed short.
+ */
+ @Test
+ public void theShortBatteryWordsStayShortEnoughForARow() {
+ for (final int id : new int[] {
+ R.string.battery_short_full,
+ R.string.battery_short_medium,
+ R.string.battery_short_low,
+ R.string.battery_short_very_low,
+ }) {
+ final String word = this.context.getString(id);
+ assertTrue("\"" + word + "\" is too long for a tag card line", word.length() <= 20);
+ }
+ }
+
+ /** The card line reads e.g. "Nearby · Battery full", so the format has to take the word. */
+ @Test
+ public void theNearbyLineFormatsWithABatteryWord() {
+ final String line = this.context.getString(R.string.nearby_now_with_battery,
+ this.context.getString(R.string.battery_short_low));
+
+ assertTrue("the battery word should appear in the line: " + line,
+ line.contains(this.context.getString(R.string.battery_short_low)));
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index 79080167..4c2b51c9 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -47,6 +47,7 @@
import java.util.Date;
import java.util.List;
import java.util.Locale;
+import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -54,6 +55,9 @@
import dev.wander.android.opentagviewer.ble.BleSoundTriggerPhase;
import dev.wander.android.opentagviewer.ble.BleSoundTriggerResult;
import dev.wander.android.opentagviewer.ble.BleSoundTriggerUpdate;
+import dev.wander.android.opentagviewer.ble.NearbyTagLabel;
+import dev.wander.android.opentagviewer.ble.NearbyTagSighting;
+import dev.wander.android.opentagviewer.ble.NearbyTagWatcher;
import dev.wander.android.opentagviewer.data.model.BeaconInformation;
import dev.wander.android.opentagviewer.data.model.UserMapCameraPosition;
import dev.wander.android.opentagviewer.databinding.ActivityDeviceInfoBinding;
@@ -140,6 +144,16 @@ public class DeviceInfoActivity extends AppCompatActivity
* on screen instead of queuing behind it - see {@link #showPlaySoundStatus}. */
private Toast playSoundStatusToast;
+ /**
+ * Listens for this one tag while the screen is open, to show a battery reading taken off the
+ * tag itself rather than out of the iCloud record.
+ *
+ *
Its own scan rather than one handed over from the map. Opening this screen
+ * pauses {@code MapsActivity}, which stops that scan, so a sighting passed across would be
+ * stale on arrival - and this screen can also be reached without the map having run at all.
+ */
+ private Disposable nearbyWatchDisposable;
+
private boolean hasNameChanges = false;
@Override
@@ -596,8 +610,65 @@ private void hideEmojiMenu() {
.start();
}
+ @Override
+ protected void onResume() {
+ super.onResume();
+ this.startWatchingForThisTag();
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ this.stopWatchingForThisTag();
+ }
+
+ /**
+ * Listen for this tag while the screen is open, to fill in the live battery row.
+ *
+ *
Silent when it cannot run - no permission, Bluetooth off, or an accessory JSON that has
+ * not been backfilled all simply leave the row hidden, which is what it looks like when the
+ * tag is out of earshot anyway.
+ */
+ private void startWatchingForThisTag() {
+ this.stopWatchingForThisTag();
+
+ final String accessoryJson = this.beaconData.getOwnedBeaconInfo().accessoryJson;
+ if (accessoryJson == null || accessoryJson.isEmpty()) {
+ return;
+ }
+
+ this.nearbyWatchDisposable = new NearbyTagWatcher(AppDependencies.accessoryMacResolver())
+ .watch(this.getApplicationContext(), Map.of(this.beaconId, accessoryJson))
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ this::showLiveBattery,
+ error -> Log.w(TAG, "Nearby watch ended for beaconId=" + this.beaconId, error));
+ }
+
+ private void stopWatchingForThisTag() {
+ if (this.nearbyWatchDisposable != null && !this.nearbyWatchDisposable.isDisposed()) {
+ this.nearbyWatchDisposable.dispose();
+ }
+ this.nearbyWatchDisposable = null;
+ }
+
+ /**
+ * Shows the battery level the tag just reported over the air.
+ *
+ *
Appears only once the tag has actually been heard, and never falls back to the iCloud
+ * value: the whole reason this row is outside the debug panel is that it cannot be stale, so
+ * quietly filling it from the record that can would defeat it. The debug row keeps that
+ * value, with its caveat.
+ */
+ private void showLiveBattery(final NearbyTagSighting sighting) {
+ this.binding.setLiveBatteryLevel(this.getString(
+ NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())));
+ this.findViewById(R.id.device_settings_live_battery).setVisibility(VISIBLE);
+ }
+
@Override
protected void onDestroy() {
+ this.stopWatchingForThisTag();
if (this.hardwareLookup != null && !this.hardwareLookup.isDisposed()) {
this.hardwareLookup.dispose();
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index b5318fd8..0b2f38d5 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -132,6 +132,10 @@
import dev.wander.android.opentagviewer.ble.BleSoundTriggerPhase;
import dev.wander.android.opentagviewer.ble.BleSoundTriggerStatus;
import dev.wander.android.opentagviewer.ble.BleSoundTriggerUpdate;
+import dev.wander.android.opentagviewer.ble.NearbyTagLabel;
+import dev.wander.android.opentagviewer.ble.NearbyTagSighting;
+import dev.wander.android.opentagviewer.ble.NearbyTagSightings;
+import dev.wander.android.opentagviewer.ble.NearbyTagWatcher;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Observable;
@@ -213,6 +217,18 @@ public class MapsActivity extends AppCompatActivity implements IMapProvider.OnMa
* no context of its own - knows what to start once it is granted. */
private String ringPermissionRequestBeaconId;
+ /**
+ * Tags this phone can hear right now, and how full their batteries say they are.
+ *
+ *
Fed by a scan that runs only while this screen is resumed, so it is a display of what
+ * is audible rather than any kind of tracking. Entries age out on their own - see
+ * {@link NearbyTagSightings}.
+ */
+ private final NearbyTagSightings nearbySightings = new NearbyTagSightings();
+
+ /** The in-flight nearby scan, disposed in {@link #onPause()} so the radio stops with the screen. */
+ private Disposable nearbyWatchDisposable;
+
/** Location history plus the "can this be drawn" rule. See BeaconLocationHistoryTest. */
private final BeaconLocationHistory beaconLocations = new BeaconLocationHistory();
@@ -519,6 +535,8 @@ public void onMapReady(IMapProvider provider) {
protected void onPause() {
super.onPause();
+ this.stopWatchingForNearbyTags();
+
if (this.mapProvider != null) {
IMapProvider.CameraPosition pos = this.mapProvider.getCameraPosition();
if (pos != null) {
@@ -570,7 +588,8 @@ protected void onResume() {
// on this screen until the periodic read came round.
this.rereadTheAccountIfAllowed(true);
this.reSchedulePeriodicTagLocationRefresher();
-
+ this.startWatchingForNearbyTags();
+
// 调用高德地图的生命周期方法
if (this.mapProvider instanceof AMapProvider) {
((AMapProvider) this.mapProvider).onResume();
@@ -601,6 +620,81 @@ protected void onStop() {
this.stopContinuousPing();
}
+ /**
+ * Listen for the user's own tags for as long as this screen is in front of somebody.
+ *
+ *
Tied to the screen rather than to a service, deliberately. Nothing here runs in
+ * the background: this starts in {@code onResume} and is disposed in {@code onPause}, so the
+ * radio is on only while a person is looking at the result. That keeps it a display feature
+ * rather than a tracking one, and a scan next to a lit screen costs little beside the screen.
+ *
+ *
Silent when it cannot run. No permission, Bluetooth off, or no tags with usable
+ * accessory JSON all mean no sightings, which renders as no badges - the same as hearing
+ * nothing. None of those is worth interrupting somebody looking at a map for.
+ */
+ private void startWatchingForNearbyTags() {
+ this.stopWatchingForNearbyTags();
+
+ final Map accessoryJsonByBeaconId = new HashMap<>();
+ for (final var entry : this.beacons.entrySet()) {
+ final String accessoryJson = entry.getValue().getInfo().getOwnedBeaconAccessoryJson();
+ if (accessoryJson != null && !accessoryJson.isEmpty()) {
+ accessoryJsonByBeaconId.put(entry.getKey(), accessoryJson);
+ }
+ }
+ if (accessoryJsonByBeaconId.isEmpty()) {
+ return;
+ }
+
+ this.nearbyWatchDisposable = new NearbyTagWatcher(AppDependencies.accessoryMacResolver())
+ .watch(this.getApplicationContext(), accessoryJsonByBeaconId)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ this::onTagHeardNearby,
+ error -> Log.w(TAG, "Nearby tag watch ended unexpectedly", error));
+ }
+
+ private void stopWatchingForNearbyTags() {
+ if (this.nearbyWatchDisposable != null && !this.nearbyWatchDisposable.isDisposed()) {
+ this.nearbyWatchDisposable.dispose();
+ }
+ this.nearbyWatchDisposable = null;
+ // Nothing on screen may go on claiming a tag is here once we have stopped listening.
+ this.nearbySightings.clear();
+ }
+
+ /**
+ * Redraws one card when its tag is heard.
+ *
+ * Only that card, and only when it exists: a sighting arrives per advertisement, which is
+ * every second or two per tag, and redrawing the whole row that often would be visible.
+ */
+ private void onTagHeardNearby(final NearbyTagSighting sighting) {
+ this.nearbySightings.record(sighting);
+
+ final FrameLayout card = this.dynamicCardsForTag.get(sighting.getBeaconId());
+ if (card != null) {
+ this.showNearbyStatusOn(card, sighting);
+ }
+ }
+
+ /**
+ * Replaces a card's "last updated" line while its tag is audible.
+ *
+ *
The two say different things and the newer one wins: "last updated two hours ago"
+ * describes when Apple's network last reported it, while a sighting means this phone can
+ * hear it right now. Showing both would need a taller card, and the row is already measured
+ * to the pixel - see {@code TagCardLayoutTest}.
+ *
+ *
The line goes back to the timestamp on its own once the sighting ages out, because
+ * nothing announces that a tag has left; we simply stop hearing it.
+ */
+ private void showNearbyStatusOn(final FrameLayout card, final NearbyTagSighting sighting) {
+ final TextView line = card.findViewById(R.id.device_last_update);
+ line.setText(this.getString(R.string.nearby_now_with_battery,
+ this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel()))));
+ }
+
@Override
protected void onDestroy() {
super.onDestroy();
@@ -2208,14 +2302,21 @@ private synchronized void updateBeaconCards() {
deviceLocation.setText(geoLocation.getAddressLine(0));
}
- // the last updated time
+ // the last updated time - unless the tag is audible right now, which is both newer
+ // and more useful than when Apple's network last reported it. See
+ // showNearbyStatusOn; a sighting ages out on its own, so this line comes back.
TextView deviceLastUpdate = v.findViewById(R.id.device_last_update);
- final var timeAgo = DateUtils.getRelativeTimeSpanString(
- lastLocation.getTimestamp(),
- now,
- DateUtils.MINUTE_IN_MILLIS
- ).toString();
- deviceLastUpdate.setText(this.getString(R.string.last_updated_x, timeAgo));
+ final NearbyTagSighting heardNow = this.nearbySightings.freshFor(beaconId, now);
+ if (heardNow != null) {
+ this.showNearbyStatusOn(v, heardNow);
+ } else {
+ final var timeAgo = DateUtils.getRelativeTimeSpanString(
+ lastLocation.getTimestamp(),
+ now,
+ DateUtils.MINUTE_IN_MILLIS
+ ).toString();
+ deviceLastUpdate.setText(this.getString(R.string.last_updated_x, timeAgo));
+ }
// **Put an existing card where it now belongs.** Cards are created once and reused,
// so a card added before the user rearranged anything keeps its original slot
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java
new file mode 100644
index 00000000..978de021
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java
@@ -0,0 +1,36 @@
+package dev.wander.android.opentagviewer.ble;
+
+import androidx.annotation.StringRes;
+
+import dev.wander.android.opentagviewer.R;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+/**
+ * Which string resources describe a sighting, decided without touching a {@code Context}.
+ *
+ *
Split out so the choice is covered by a JVM test. Formatting it needs resources and a
+ * locale, but choosing which resource does not, and the choice is the part with rules in
+ * it.
+ */
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class NearbyTagLabel {
+
+ /**
+ * The short battery word for a tag card, e.g. "low".
+ *
+ *
Deliberately not the {@code battery_level_*} strings the debug panel uses. Those spell
+ * out a percentage range and a caveat, which is right for a diagnostics row and far too long
+ * for a line that also has to carry "Nearby" on a card sized to a phone.
+ */
+ @StringRes
+ public static int shortBatteryLabel(final FindMyAdvertisement.BatteryLevel level) {
+ switch (level) {
+ case MEDIUM: return R.string.battery_short_medium;
+ case LOW: return R.string.battery_short_low;
+ case VERY_LOW: return R.string.battery_short_very_low;
+ case FULL:
+ default: return R.string.battery_short_full;
+ }
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSightings.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSightings.java
new file mode 100644
index 00000000..62391337
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSightings.java
@@ -0,0 +1,56 @@
+package dev.wander.android.opentagviewer.ble;
+
+import androidx.annotation.Nullable;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * The most recent sighting of each tag, and whether it is recent enough to still show.
+ *
+ *
The ageing is the point. A sighting is a claim about a moment, so one left on screen
+ * becomes a lie as soon as the tag is carried out of range: the badge would still read "nearby"
+ * for a tag that is gone. Nothing tells us it left - we simply stop hearing it - so the only
+ * honest rendering is to let the claim expire on its own.
+ *
+ *
No Android in here, and the clock is a parameter, so the expiry rule is covered by a JVM
+ * test rather than by watching a screen and waiting.
+ */
+public final class NearbyTagSightings {
+
+ /**
+ * How long a sighting is worth showing.
+ *
+ *
A separated accessory advertises every second or two, but
+ * {@code SCAN_MODE_LOW_POWER} only listens in short windows a few seconds apart, so gaps of
+ * several seconds are normal for a tag sitting right next to the phone. This is generous
+ * enough to ride those out and short enough that a tag carried away stops claiming to be
+ * here within about half a minute.
+ */
+ static final long FRESH_FOR_MS = TimeUnit.SECONDS.toMillis(30);
+
+ private final Map latestByBeaconId = new ConcurrentHashMap<>();
+
+ /** Written from the scan callback, read on the main thread, hence the concurrent map. */
+ public void record(final NearbyTagSighting sighting) {
+ this.latestByBeaconId.put(sighting.getBeaconId(), sighting);
+ }
+
+ /**
+ * The last sighting of this tag, or null if there is none or it is too old to stand behind.
+ */
+ @Nullable
+ public NearbyTagSighting freshFor(final String beaconId, final long nowMs) {
+ final NearbyTagSighting sighting = this.latestByBeaconId.get(beaconId);
+ if (sighting == null || nowMs - sighting.getSeenAtMs() >= FRESH_FOR_MS) {
+ return null;
+ }
+ return sighting;
+ }
+
+ /** Drops everything, for when scanning stops and nothing may keep claiming to be current. */
+ public void clear() {
+ this.latestByBeaconId.clear();
+ }
+}
diff --git a/app/src/main/res/layout/activity_device_info.xml b/app/src/main/res/layout/activity_device_info.xml
index 8644886e..bbfd1545 100644
--- a/app/src/main/res/layout/activity_device_info.xml
+++ b/app/src/main/res/layout/activity_device_info.xml
@@ -102,6 +102,12 @@
name="batteryLevel"
type="String" />
+
+
+
@@ -385,6 +391,27 @@
app:sectionSubtitle="@{deviceType}"
app:title="@{@string/type}" />
+
+
+
+ %1$d m
+ %1$d ft
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDistanceLabelTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDistanceLabelTest.java
new file mode 100644
index 00000000..22d4d101
--- /dev/null
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDistanceLabelTest.java
@@ -0,0 +1,57 @@
+package dev.wander.android.opentagviewer.ble;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+import java.util.Locale;
+
+import dev.wander.android.opentagviewer.R;
+
+/**
+ * A JVM test: the unit decision is locale logic with no {@code Context} in it - see the class
+ * doc on {@link NearbyDistanceLabel}.
+ */
+public class NearbyDistanceLabelTest {
+
+ private static final Locale GERMANY = Locale.GERMANY;
+ private static final Locale UNITED_STATES = Locale.US;
+ private static final Locale UNITED_KINGDOM = Locale.UK;
+
+ @Test
+ public void mostLocalesGetMetres() {
+ assertEquals(R.string.distance_metres, NearbyDistanceLabel.unitStringFor(GERMANY));
+ }
+
+ @Test
+ public void theUnitedKingdomAlsoGetsMetres() {
+ // Miles for road distances, but an arm's-length "how far is my tag" reading is metres
+ // there same as on the continent - see the class doc on why UK is not in the feet list.
+ assertEquals(R.string.distance_metres, NearbyDistanceLabel.unitStringFor(UNITED_KINGDOM));
+ }
+
+ @Test
+ public void theUnitedStatesGetsFeet() {
+ assertEquals(R.string.distance_feet, NearbyDistanceLabel.unitStringFor(UNITED_STATES));
+ }
+
+ @Test
+ public void roundsToTheNearestMetre() {
+ assertEquals(5, NearbyDistanceLabel.roundedValueFor(4.6, GERMANY));
+ assertEquals(4, NearbyDistanceLabel.roundedValueFor(4.4, GERMANY));
+ }
+
+ @Test
+ public void convertsToFeetForTheUnitedStates() {
+ // 3 metres is just under 10 feet (9.84), so this also exercises the rounding.
+ assertEquals(10, NearbyDistanceLabel.roundedValueFor(3.0, UNITED_STATES));
+ }
+
+ @Test
+ public void neverRoundsDownToZero() {
+ // Already a rough estimate - "~0 m" would read as a more exact claim than "~1 m" while
+ // being no truer.
+ assertEquals(1, NearbyDistanceLabel.roundedValueFor(0.1, GERMANY));
+ assertEquals(1, NearbyDistanceLabel.roundedValueFor(0.1, UNITED_STATES));
+ }
+}
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/RssiDistanceTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/RssiDistanceTest.java
new file mode 100644
index 00000000..a8e84fbd
--- /dev/null
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/RssiDistanceTest.java
@@ -0,0 +1,44 @@
+package dev.wander.android.opentagviewer.ble;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+/** A JVM test: {@link RssiDistance} is pure math, deliberately. */
+public class RssiDistanceTest {
+
+ @Test
+ public void theReferenceRssiEstimatesAboutOneMetre() {
+ assertEquals(1.0, RssiDistance.estimateMetres(-59), 0.001);
+ }
+
+ @Test
+ public void aWeakerSignalEstimatesFurtherAway() {
+ final double closer = RssiDistance.estimateMetres(-59);
+ final double further = RssiDistance.estimateMetres(-79);
+
+ assertTrue("a 20 dB weaker signal must estimate a larger distance",
+ further > closer);
+ }
+
+ @Test
+ public void aStrongerSignalEstimatesCloser() {
+ final double atReference = RssiDistance.estimateMetres(-59);
+ final double stronger = RssiDistance.estimateMetres(-39);
+
+ assertTrue("a 20 dB stronger signal must estimate a smaller distance",
+ stronger < atReference);
+ }
+
+ @Test
+ public void tenDbWeakerRoughlyTriplesTheEstimate() {
+ // The model is 10^((ref - rssi) / (10 * n)) with n = 2, so a 10 dB step is a factor of
+ // 10^0.5 ~= 3.16 - checked here rather than assumed, since it is the whole shape of the
+ // curve a caller sees as "further away".
+ final double atReference = RssiDistance.estimateMetres(-59);
+ final double tenDbWeaker = RssiDistance.estimateMetres(-69);
+
+ assertEquals(Math.sqrt(10), tenDbWeaker / atReference, 0.01);
+ }
+}
From 17e63b3a80094f0f902af210e5845922ef3ed905 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 14:17:18 +0200
Subject: [PATCH 13/61] Replace the distance estimate with a signal strength
word
Field-tested the metre estimate against two real accessories at a
measured 50 cm, then at 2 m: RSSI at 2 m (-64 to -71 dBm) overlapped RSSI
at 50 cm (-52 to -71 dBm) almost entirely. The noise from multipath and
antenna orientation on a desk is larger than the signal difference
between those two distances, so no reference-RSSI constant could have
told them apart - this was not a calibration bug, it is a limit of RSSI
at short range in this environment.
Showing "~1 m" while standing at 2 m keeps implying a precision the
signal does not have, calibrated or not. NearbyTagLabel.signalStrengthLabel
makes the weaker claim that is actually true instead: strong/medium/weak
from the raw RSSI, which still rises and falls as someone moves closer or
further - useful for homing in on a tag by ear and eye, without a number
attached to it.
RssiDistance and NearbyDistanceLabel (the metre/feet model and its
locale-based unit choice) are removed along with their tests.
---
.../opentagviewer/DeviceInfoActivity.java | 11 +---
.../android/opentagviewer/MapsActivity.java | 17 +----
.../ble/NearbyDistanceLabel.java | 52 ---------------
.../opentagviewer/ble/NearbyTagLabel.java | 27 ++++++++
.../opentagviewer/ble/RssiDistance.java | 49 --------------
app/src/main/res/values-de/strings.xml | 8 ++-
app/src/main/res/values/strings.xml | 12 ++--
.../ble/NearbyDistanceLabelTest.java | 57 ----------------
.../opentagviewer/ble/NearbyTagLabelTest.java | 65 +++++++++++++++++++
.../opentagviewer/ble/RssiDistanceTest.java | 44 -------------
10 files changed, 106 insertions(+), 236 deletions(-)
delete mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDistanceLabel.java
delete mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/RssiDistance.java
delete mode 100644 app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDistanceLabelTest.java
create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagLabelTest.java
delete mode 100644 app/src/test/java/dev/wander/android/opentagviewer/ble/RssiDistanceTest.java
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index 066be19f..1029d02f 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -55,9 +55,7 @@
import dev.wander.android.opentagviewer.ble.BleSoundTriggerPhase;
import dev.wander.android.opentagviewer.ble.BleSoundTriggerResult;
import dev.wander.android.opentagviewer.ble.BleSoundTriggerUpdate;
-import dev.wander.android.opentagviewer.ble.NearbyDistanceLabel;
import dev.wander.android.opentagviewer.ble.NearbyTagLabel;
-import dev.wander.android.opentagviewer.ble.RssiDistance;
import dev.wander.android.opentagviewer.ble.NearbyTagSighting;
import dev.wander.android.opentagviewer.ble.NearbyTagWatcher;
import dev.wander.android.opentagviewer.data.model.BeaconInformation;
@@ -663,14 +661,9 @@ private void stopWatchingForThisTag() {
* value, with its caveat.
*/
private void showLiveBattery(final NearbyTagSighting sighting) {
- final Locale locale = this.getResources().getConfiguration().getLocales().get(0);
- final double metres = RssiDistance.estimateMetres(sighting.getRssi());
- final String distance = this.getString(NearbyDistanceLabel.unitStringFor(locale),
- NearbyDistanceLabel.roundedValueFor(metres, locale));
-
- this.binding.setLiveBatteryLevel(this.getString(R.string.live_battery_with_distance,
+ this.binding.setLiveBatteryLevel(this.getString(R.string.live_battery_with_signal,
this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())),
- distance));
+ this.getString(NearbyTagLabel.signalStrengthLabel(sighting.getRssi()))));
this.findViewById(R.id.device_settings_live_battery).setVisibility(VISIBLE);
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index 637e59fd..b455a16d 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -132,9 +132,7 @@
import dev.wander.android.opentagviewer.ble.BleSoundTriggerPhase;
import dev.wander.android.opentagviewer.ble.BleSoundTriggerStatus;
import dev.wander.android.opentagviewer.ble.BleSoundTriggerUpdate;
-import dev.wander.android.opentagviewer.ble.NearbyDistanceLabel;
import dev.wander.android.opentagviewer.ble.NearbyTagLabel;
-import dev.wander.android.opentagviewer.ble.RssiDistance;
import dev.wander.android.opentagviewer.ble.NearbyTagSighting;
import dev.wander.android.opentagviewer.ble.NearbyTagSightings;
import dev.wander.android.opentagviewer.ble.NearbyTagWatcher;
@@ -705,20 +703,9 @@ private void onTagHeardNearby(final NearbyTagSighting sighting) {
*/
private void showNearbyStatusOn(final FrameLayout card, final NearbyTagSighting sighting) {
final TextView line = card.findViewById(R.id.device_last_update);
- line.setText(this.getString(R.string.nearby_now_with_battery_and_distance,
+ line.setText(this.getString(R.string.nearby_now_with_battery_and_signal,
this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())),
- this.distanceStringFor(sighting)));
- }
-
- /**
- * A rough "~5 m"/"~16 ft" for how far away a sighting's signal strength puts the tag - see
- * {@link RssiDistance} for why this is an estimate and not a measurement.
- */
- private String distanceStringFor(final NearbyTagSighting sighting) {
- final Locale locale = this.getResources().getConfiguration().getLocales().get(0);
- final double metres = RssiDistance.estimateMetres(sighting.getRssi());
- return this.getString(NearbyDistanceLabel.unitStringFor(locale),
- NearbyDistanceLabel.roundedValueFor(metres, locale));
+ this.getString(NearbyTagLabel.signalStrengthLabel(sighting.getRssi()))));
}
@Override
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDistanceLabel.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDistanceLabel.java
deleted file mode 100644
index 8c918483..00000000
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDistanceLabel.java
+++ /dev/null
@@ -1,52 +0,0 @@
-package dev.wander.android.opentagviewer.ble;
-
-import androidx.annotation.StringRes;
-
-import java.util.Locale;
-
-import dev.wander.android.opentagviewer.R;
-import lombok.AccessLevel;
-import lombok.NoArgsConstructor;
-
-/**
- * Which unit a {@link RssiDistance} estimate should show in, and the rounded number for it.
- *
- * Split from {@link RssiDistance} for the same reason {@link NearbyTagLabel} is split from
- * the strings it points at: the unit decision is locale logic with rules in it, reachable by a
- * JVM test without a {@code Context}; turning the chosen resource into words on screen still
- * needs one.
- */
-@NoArgsConstructor(access = AccessLevel.PRIVATE)
-public final class NearbyDistanceLabel {
-
- private static final double METRES_TO_FEET = 3.28084;
-
- /**
- * Countries that read a short distance in feet rather than metres.
- *
- *
The three ICU's own locale data lists as customary-unit countries. The United Kingdom
- * is deliberately not here despite miles-for-road-distances - the everyday, arm's-length
- * distances this label is for are given in metres there, same as on the continent.
- */
- private static boolean usesImperialUnits(final Locale locale) {
- final String country = locale.getCountry();
- return "US".equals(country) || "LR".equals(country) || "MM".equals(country);
- }
-
- /**
- * The estimate rounded to a whole number in whichever unit {@code locale} prefers.
- *
- *
Clamped to at least one. This is already a rough estimate - see {@link RssiDistance} -
- * and "~0 m" would read as a more exact claim than "~1 m" while being no truer.
- */
- public static int roundedValueFor(final double metres, final Locale locale) {
- final double inPreferredUnit = usesImperialUnits(locale) ? metres * METRES_TO_FEET : metres;
- return Math.max(1, (int) Math.round(inPreferredUnit));
- }
-
- /** The unit word to format {@link #roundedValueFor} with, e.g. {@code "%1$d m"}. */
- @StringRes
- public static int unitStringFor(final Locale locale) {
- return usesImperialUnits(locale) ? R.string.distance_feet : R.string.distance_metres;
- }
-}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java
index 978de021..39a992d4 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java
@@ -33,4 +33,31 @@ public static int shortBatteryLabel(final FindMyAdvertisement.BatteryLevel level
default: return R.string.battery_short_full;
}
}
+
+ /**
+ * A signal strength word for a sighting's RSSI - "strong", "medium" or "weak".
+ *
+ *
Deliberately not a distance. An earlier version of this feature converted RSSI
+ * to metres through the standard log-distance path loss model, calibrated against a real
+ * accessory at a measured 50 cm. Moved to 2 m, the same accessory read RSSI values that
+ * overlapped the readings taken at 50 cm - the noise from multipath and antenna orientation
+ * on a desk was larger than the signal difference between those two distances, so no
+ * calibration constant could have told them apart. A number would have kept implying a
+ * precision the underlying signal does not have. A strength word makes a weaker claim that
+ * is actually true: the reading went up or down, which is still useful for homing in on a
+ * tag while moving, without pretending to say by how far.
+ *
+ *
Thresholds are not calibrated to a particular distance for that reason - they only
+ * need to separate stronger readings from weaker ones as someone moves.
+ */
+ @StringRes
+ public static int signalStrengthLabel(final int rssi) {
+ if (rssi >= -60) {
+ return R.string.signal_strength_strong;
+ }
+ if (rssi >= -75) {
+ return R.string.signal_strength_medium;
+ }
+ return R.string.signal_strength_weak;
+ }
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/RssiDistance.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/RssiDistance.java
deleted file mode 100644
index 3302cf4d..00000000
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/RssiDistance.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package dev.wander.android.opentagviewer.ble;
-
-import lombok.AccessLevel;
-import lombok.NoArgsConstructor;
-
-/**
- * A rough distance estimate from a BLE advertisement's signal strength.
- *
- *
An estimate, not a measurement, and a rough one. The log-distance path loss model
- * this uses assumes free space and a fixed transmit power; a pocket, a wall, or the accessory's
- * own antenna orientation shifts the reading by metres, not centimetres, and two accessories
- * standing side by side can report different distances for it. It is worth showing anyway
- * because a coarse "closer" or "further" as someone walks is still useful for homing in on a
- * tag by eye - it is not worth presenting as anything more precise than that, and nothing here
- * claims otherwise.
- *
- *
No Android in here, so the model is reachable by a JVM test.
- */
-@NoArgsConstructor(access = AccessLevel.PRIVATE)
-public final class RssiDistance {
-
- /**
- * Assumed RSSI at one metre, in dBm.
- *
- *
The de facto default for exactly this reason - most BLE accessories (AirTags among
- * them) do not publish a calibrated transmit power over an advertisement a stranger's phone
- * can read, so there is no per-device value to use instead. A real accessory can plausibly
- * sit several dB either side of this.
- */
- private static final double REFERENCE_RSSI_AT_ONE_METRE = -59.0;
-
- /**
- * Path loss exponent for free space. Indoors, behind obstacles, or through a pocket, the
- * true exponent runs higher - which this does not attempt to detect, so a reading is always
- * biased toward "closer than it looks" in those cases rather than toward "further".
- */
- private static final double PATH_LOSS_EXPONENT = 2.0;
-
- /**
- * Estimated distance in metres for one advertisement's RSSI.
- *
- *
The standard log-distance path loss model, solved for distance:
- * {@code 10 ^ ((referenceRssi - rssi) / (10 * pathLossExponent))}.
- */
- public static double estimateMetres(final int rssi) {
- return Math.pow(10.0,
- (REFERENCE_RSSI_AT_ONE_METRE - rssi) / (10.0 * PATH_LOSS_EXPONENT));
- }
-}
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 38c14784..febf9d83 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -327,13 +327,15 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Klingelt!
In der Nähe
In der Nähe · Akku %1$s
- In der Nähe (~%2$s) · Akku %1$s
+ In der Nähe (Signal %2$s) · Akku %1$s
Gerade per Bluetooth vom Tag selbst gelesen, nicht aus iCloud.
voll
mittel
niedrig
kritisch
Akku
- %1$s · ~%2$s entfernt
- %1$d m
+ %1$s · Signal %2$s
+ stark
+ mittel
+ schwach
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4f5f9cee..19776ffd 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -359,17 +359,15 @@ You can set this up now, or any time later from Settings.
Ringing!
Nearby
Nearby · Battery %1$s
- Nearby (~%2$s) · Battery %1$s
+ Nearby (%2$s signal) · Battery %1$s
Read from the tag over Bluetooth just now, not from iCloud.
full
medium
low
critical
Battery
- %1$s · ~%2$s away
-
- %1$d m
- %1$d ft
+ %1$s · %2$s signal
+ strong
+ medium
+ weak
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDistanceLabelTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDistanceLabelTest.java
deleted file mode 100644
index 22d4d101..00000000
--- a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDistanceLabelTest.java
+++ /dev/null
@@ -1,57 +0,0 @@
-package dev.wander.android.opentagviewer.ble;
-
-import static org.junit.Assert.assertEquals;
-
-import org.junit.Test;
-
-import java.util.Locale;
-
-import dev.wander.android.opentagviewer.R;
-
-/**
- * A JVM test: the unit decision is locale logic with no {@code Context} in it - see the class
- * doc on {@link NearbyDistanceLabel}.
- */
-public class NearbyDistanceLabelTest {
-
- private static final Locale GERMANY = Locale.GERMANY;
- private static final Locale UNITED_STATES = Locale.US;
- private static final Locale UNITED_KINGDOM = Locale.UK;
-
- @Test
- public void mostLocalesGetMetres() {
- assertEquals(R.string.distance_metres, NearbyDistanceLabel.unitStringFor(GERMANY));
- }
-
- @Test
- public void theUnitedKingdomAlsoGetsMetres() {
- // Miles for road distances, but an arm's-length "how far is my tag" reading is metres
- // there same as on the continent - see the class doc on why UK is not in the feet list.
- assertEquals(R.string.distance_metres, NearbyDistanceLabel.unitStringFor(UNITED_KINGDOM));
- }
-
- @Test
- public void theUnitedStatesGetsFeet() {
- assertEquals(R.string.distance_feet, NearbyDistanceLabel.unitStringFor(UNITED_STATES));
- }
-
- @Test
- public void roundsToTheNearestMetre() {
- assertEquals(5, NearbyDistanceLabel.roundedValueFor(4.6, GERMANY));
- assertEquals(4, NearbyDistanceLabel.roundedValueFor(4.4, GERMANY));
- }
-
- @Test
- public void convertsToFeetForTheUnitedStates() {
- // 3 metres is just under 10 feet (9.84), so this also exercises the rounding.
- assertEquals(10, NearbyDistanceLabel.roundedValueFor(3.0, UNITED_STATES));
- }
-
- @Test
- public void neverRoundsDownToZero() {
- // Already a rough estimate - "~0 m" would read as a more exact claim than "~1 m" while
- // being no truer.
- assertEquals(1, NearbyDistanceLabel.roundedValueFor(0.1, GERMANY));
- assertEquals(1, NearbyDistanceLabel.roundedValueFor(0.1, UNITED_STATES));
- }
-}
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagLabelTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagLabelTest.java
new file mode 100644
index 00000000..47b83dee
--- /dev/null
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagLabelTest.java
@@ -0,0 +1,65 @@
+package dev.wander.android.opentagviewer.ble;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+import dev.wander.android.opentagviewer.R;
+
+/** A JVM test: {@link NearbyTagLabel} chooses resources without touching a {@code Context}. */
+public class NearbyTagLabelTest {
+
+ @Test
+ public void shortBatteryLabelPicksTheMatchingWord() {
+ assertEquals(R.string.battery_short_full,
+ NearbyTagLabel.shortBatteryLabel(FindMyAdvertisement.BatteryLevel.FULL));
+ assertEquals(R.string.battery_short_medium,
+ NearbyTagLabel.shortBatteryLabel(FindMyAdvertisement.BatteryLevel.MEDIUM));
+ assertEquals(R.string.battery_short_low,
+ NearbyTagLabel.shortBatteryLabel(FindMyAdvertisement.BatteryLevel.LOW));
+ assertEquals(R.string.battery_short_very_low,
+ NearbyTagLabel.shortBatteryLabel(FindMyAdvertisement.BatteryLevel.VERY_LOW));
+ }
+
+ @Test
+ public void aStrongSignalIsStrong() {
+ assertEquals(R.string.signal_strength_strong, NearbyTagLabel.signalStrengthLabel(-50));
+ assertEquals(R.string.signal_strength_strong, NearbyTagLabel.signalStrengthLabel(-60));
+ }
+
+ @Test
+ public void aMidRangeSignalIsMedium() {
+ assertEquals(R.string.signal_strength_medium, NearbyTagLabel.signalStrengthLabel(-61));
+ assertEquals(R.string.signal_strength_medium, NearbyTagLabel.signalStrengthLabel(-75));
+ }
+
+ @Test
+ public void aFaintSignalIsWeak() {
+ assertEquals(R.string.signal_strength_weak, NearbyTagLabel.signalStrengthLabel(-76));
+ assertEquals(R.string.signal_strength_weak, NearbyTagLabel.signalStrengthLabel(-95));
+ }
+
+ @Test
+ public void aStrongerReadingNeverRanksBelowAWeakerOne() {
+ // The whole point of showing this at all: as a reading improves while someone moves,
+ // the label must not go backwards.
+ final int[] fromWeakToStrong = {-95, -80, -75, -70, -60, -50};
+ int previousRank = -1;
+ for (final int rssi : fromWeakToStrong) {
+ final int rank = rankOf(NearbyTagLabel.signalStrengthLabel(rssi));
+ org.junit.Assert.assertTrue(
+ "rssi=" + rssi + " ranked below a weaker reading", rank >= previousRank);
+ previousRank = rank;
+ }
+ }
+
+ private static int rankOf(final int stringRes) {
+ if (stringRes == R.string.signal_strength_weak) {
+ return 0;
+ }
+ if (stringRes == R.string.signal_strength_medium) {
+ return 1;
+ }
+ return 2;
+ }
+}
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/RssiDistanceTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/RssiDistanceTest.java
deleted file mode 100644
index a8e84fbd..00000000
--- a/app/src/test/java/dev/wander/android/opentagviewer/ble/RssiDistanceTest.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package dev.wander.android.opentagviewer.ble;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
-
-import org.junit.Test;
-
-/** A JVM test: {@link RssiDistance} is pure math, deliberately. */
-public class RssiDistanceTest {
-
- @Test
- public void theReferenceRssiEstimatesAboutOneMetre() {
- assertEquals(1.0, RssiDistance.estimateMetres(-59), 0.001);
- }
-
- @Test
- public void aWeakerSignalEstimatesFurtherAway() {
- final double closer = RssiDistance.estimateMetres(-59);
- final double further = RssiDistance.estimateMetres(-79);
-
- assertTrue("a 20 dB weaker signal must estimate a larger distance",
- further > closer);
- }
-
- @Test
- public void aStrongerSignalEstimatesCloser() {
- final double atReference = RssiDistance.estimateMetres(-59);
- final double stronger = RssiDistance.estimateMetres(-39);
-
- assertTrue("a 20 dB stronger signal must estimate a smaller distance",
- stronger < atReference);
- }
-
- @Test
- public void tenDbWeakerRoughlyTriplesTheEstimate() {
- // The model is 10^((ref - rssi) / (10 * n)) with n = 2, so a 10 dB step is a factor of
- // 10^0.5 ~= 3.16 - checked here rather than assumed, since it is the whole shape of the
- // curve a caller sees as "further away".
- final double atReference = RssiDistance.estimateMetres(-59);
- final double tenDbWeaker = RssiDistance.estimateMetres(-69);
-
- assertEquals(Math.sqrt(10), tenDbWeaker / atReference, 0.01);
- }
-}
From 36c1cfeae34f0fbc536b232ed05d2c8b629ca3f7 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 14:22:12 +0200
Subject: [PATCH 14/61] Show the signal strength as five dots instead of a word
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
"Medium" as the middle of three words read oddly and only left room for
three levels. Five dots (e.g. ●●●○○) read the same way a phone's own
signal bars do, need no string resource or locale to pick one, and fit
five 10 dB bands instead of three - matching the noise band a real
accessory's readings showed while standing still (see
signalStrengthBars' doc).
---
.../opentagviewer/DeviceInfoActivity.java | 2 +-
.../android/opentagviewer/MapsActivity.java | 2 +-
.../opentagviewer/ble/NearbyTagLabel.java | 57 +++++++++++++++----
app/src/main/res/values-de/strings.xml | 3 -
app/src/main/res/values/strings.xml | 7 +--
.../opentagviewer/ble/NearbyTagLabelTest.java | 49 ++++++++--------
6 files changed, 72 insertions(+), 48 deletions(-)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index 1029d02f..db695599 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -663,7 +663,7 @@ private void stopWatchingForThisTag() {
private void showLiveBattery(final NearbyTagSighting sighting) {
this.binding.setLiveBatteryLevel(this.getString(R.string.live_battery_with_signal,
this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())),
- this.getString(NearbyTagLabel.signalStrengthLabel(sighting.getRssi()))));
+ NearbyTagLabel.signalStrengthBars(sighting.getRssi())));
this.findViewById(R.id.device_settings_live_battery).setVisibility(VISIBLE);
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index b455a16d..e0781da5 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -705,7 +705,7 @@ private void showNearbyStatusOn(final FrameLayout card, final NearbyTagSighting
final TextView line = card.findViewById(R.id.device_last_update);
line.setText(this.getString(R.string.nearby_now_with_battery_and_signal,
this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())),
- this.getString(NearbyTagLabel.signalStrengthLabel(sighting.getRssi()))));
+ NearbyTagLabel.signalStrengthBars(sighting.getRssi())));
}
@Override
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java
index 39a992d4..96d79253 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java
@@ -34,8 +34,18 @@ public static int shortBatteryLabel(final FindMyAdvertisement.BatteryLevel level
}
}
+ /** Filled dot, for {@link #signalStrengthBars}. */
+ private static final char BAR_FILLED = '●';
+
+ /** Hollow dot, for {@link #signalStrengthBars}. */
+ private static final char BAR_EMPTY = '○';
+
+ /** How many dots {@link #signalStrengthBars} draws - filled and hollow together. */
+ private static final int SIGNAL_BAR_COUNT = 5;
+
/**
- * A signal strength word for a sighting's RSSI - "strong", "medium" or "weak".
+ * A five-dot signal meter for a sighting's RSSI, e.g. {@code "●●●○○"} - no words, so no
+ * string resource and no locale to get it from.
*
*
Deliberately not a distance. An earlier version of this feature converted RSSI
* to metres through the standard log-distance path loss model, calibrated against a real
@@ -43,21 +53,44 @@ public static int shortBatteryLabel(final FindMyAdvertisement.BatteryLevel level
* overlapped the readings taken at 50 cm - the noise from multipath and antenna orientation
* on a desk was larger than the signal difference between those two distances, so no
* calibration constant could have told them apart. A number would have kept implying a
- * precision the underlying signal does not have. A strength word makes a weaker claim that
- * is actually true: the reading went up or down, which is still useful for homing in on a
- * tag while moving, without pretending to say by how far.
+ * precision the underlying signal does not have. A dot count makes a weaker claim that is
+ * actually true: the reading went up or down, which is still useful for homing in on a tag
+ * while moving, without pretending to say by how far.
*
- *
Thresholds are not calibrated to a particular distance for that reason - they only
- * need to separate stronger readings from weaker ones as someone moves.
+ *
{@link #signalStrengthLevel}'s thresholds are not calibrated to a particular distance
+ * for that reason - they only need to separate stronger readings from weaker ones as someone
+ * moves.
*/
- @StringRes
- public static int signalStrengthLabel(final int rssi) {
- if (rssi >= -60) {
- return R.string.signal_strength_strong;
+ public static String signalStrengthBars(final int rssi) {
+ final int filled = signalStrengthLevel(rssi);
+ final StringBuilder bars = new StringBuilder(SIGNAL_BAR_COUNT);
+ for (int i = 0; i < SIGNAL_BAR_COUNT; i++) {
+ bars.append(i < filled ? BAR_FILLED : BAR_EMPTY);
+ }
+ return bars.toString();
+ }
+
+ /**
+ * How many of {@link #signalStrengthBars}' five dots are filled, from 1 (weakest) to 5
+ * (strongest) - never 0, since a sighting existing at all means some signal was heard.
+ *
+ *
10 dB per step, which is also the noise band the field test behind
+ * {@link #signalStrengthBars}'s doc turned up: two readings of the same real accessory,
+ * standing still, varied by that much on their own.
+ */
+ static int signalStrengthLevel(final int rssi) {
+ if (rssi >= -55) {
+ return 5;
+ }
+ if (rssi >= -65) {
+ return 4;
}
if (rssi >= -75) {
- return R.string.signal_strength_medium;
+ return 3;
+ }
+ if (rssi >= -85) {
+ return 2;
}
- return R.string.signal_strength_weak;
+ return 1;
}
}
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index febf9d83..ea634800 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -335,7 +335,4 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.kritisch
Akku
%1$s · Signal %2$s
- stark
- mittel
- schwach
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 19776ffd..a3ad7274 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -359,15 +359,12 @@ You can set this up now, or any time later from Settings.
Ringing!
Nearby
Nearby · Battery %1$s
- Nearby (%2$s signal) · Battery %1$s
+ Nearby (signal %2$s) · Battery %1$s
Read from the tag over Bluetooth just now, not from iCloud.
full
medium
low
critical
Battery
- %1$s · %2$s signal
- strong
- medium
- weak
+ %1$s · signal %2$s
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagLabelTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagLabelTest.java
index 47b83dee..9aba6d4c 100644
--- a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagLabelTest.java
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagLabelTest.java
@@ -1,6 +1,7 @@
package dev.wander.android.opentagviewer.ble;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
import org.junit.Test;
@@ -22,44 +23,40 @@ public void shortBatteryLabelPicksTheMatchingWord() {
}
@Test
- public void aStrongSignalIsStrong() {
- assertEquals(R.string.signal_strength_strong, NearbyTagLabel.signalStrengthLabel(-50));
- assertEquals(R.string.signal_strength_strong, NearbyTagLabel.signalStrengthLabel(-60));
+ public void aStrongSignalFillsAllFiveDots() {
+ assertEquals(5, NearbyTagLabel.signalStrengthLevel(-50));
+ assertEquals("●●●●●", NearbyTagLabel.signalStrengthBars(-50));
}
@Test
- public void aMidRangeSignalIsMedium() {
- assertEquals(R.string.signal_strength_medium, NearbyTagLabel.signalStrengthLabel(-61));
- assertEquals(R.string.signal_strength_medium, NearbyTagLabel.signalStrengthLabel(-75));
+ public void aFaintSignalFillsOnlyOneDot() {
+ assertEquals(1, NearbyTagLabel.signalStrengthLevel(-95));
+ assertEquals("●○○○○", NearbyTagLabel.signalStrengthBars(-95));
}
@Test
- public void aFaintSignalIsWeak() {
- assertEquals(R.string.signal_strength_weak, NearbyTagLabel.signalStrengthLabel(-76));
- assertEquals(R.string.signal_strength_weak, NearbyTagLabel.signalStrengthLabel(-95));
+ public void neverFillsZeroDots() {
+ // A sighting existing at all means some signal was heard, however faint.
+ assertEquals(1, NearbyTagLabel.signalStrengthLevel(-200));
}
@Test
- public void aStrongerReadingNeverRanksBelowAWeakerOne() {
- // The whole point of showing this at all: as a reading improves while someone moves,
- // the label must not go backwards.
- final int[] fromWeakToStrong = {-95, -80, -75, -70, -60, -50};
- int previousRank = -1;
- for (final int rssi : fromWeakToStrong) {
- final int rank = rankOf(NearbyTagLabel.signalStrengthLabel(rssi));
- org.junit.Assert.assertTrue(
- "rssi=" + rssi + " ranked below a weaker reading", rank >= previousRank);
- previousRank = rank;
+ public void barsAlwaysHaveFiveDotsTotal() {
+ for (int rssi = -100; rssi <= -40; rssi++) {
+ assertEquals("rssi=" + rssi, 5, NearbyTagLabel.signalStrengthBars(rssi).length());
}
}
- private static int rankOf(final int stringRes) {
- if (stringRes == R.string.signal_strength_weak) {
- return 0;
- }
- if (stringRes == R.string.signal_strength_medium) {
- return 1;
+ @Test
+ public void aStrongerReadingNeverRanksBelowAWeakerOne() {
+ // The whole point of showing this at all: as a reading improves while someone moves,
+ // the dot count must not go backwards.
+ final int[] fromWeakToStrong = {-95, -85, -84, -75, -74, -65, -64, -55, -54, -50};
+ int previousLevel = 0;
+ for (final int rssi : fromWeakToStrong) {
+ final int level = NearbyTagLabel.signalStrengthLevel(rssi);
+ assertTrue("rssi=" + rssi + " ranked below a weaker reading", level >= previousLevel);
+ previousLevel = level;
}
- return 2;
}
}
From b42f23df20bcac98113a37977d02909663848a10 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 14:34:56 +0200
Subject: [PATCH 15/61] Show how long ago the map card's nearby sighting was
heard
Only on the map card, not the device info screen's live battery row:
that row updates from the same subscription that produces each
sighting, so a snapshot there would read close to zero every time.
updateBeaconCards, by contrast, redraws from the last-known sighting on
refresh cycles unrelated to a fresh one arriving, so a snapshot there is
actually informative - it says how stale the badge itself is, up to the
30 second window NearbyTagSightings holds one for.
Dropped "signal" from the parenthetical to fit: the worst case (five
dots, three-digit seconds, "critical"/"kritisch") measurably grew the
card at the wordier phrasing - caught by a new TagCardLayoutTest case
built the same way as the existing long-name/long-address ones, rather
than assumed.
---
.../ui/maps/TagCardLayoutTest.java | 45 +++++++++++++++++++
.../android/opentagviewer/MapsActivity.java | 13 ++++--
app/src/main/res/values-de/strings.xml | 2 +-
app/src/main/res/values/strings.xml | 2 +-
4 files changed, 56 insertions(+), 6 deletions(-)
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java
index f863fd7c..aa261366 100644
--- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java
@@ -169,6 +169,51 @@ public void aLongDeviceNameDoesNotMakeItsCardTaller() {
assertEquals(heights.get(0), heights.get(1));
}
+ /**
+ * {@code MapsActivity.showNearbyStatusOn}'s longest realistic line - full signal, the
+ * longest battery word, three-digit seconds - must not wrap to a second line and grow the
+ * row. Built directly rather than through {@link #measureHeights}, which always writes a
+ * fixed string to this field.
+ */
+ @Test
+ public void theLongestNearbyStatusLineDoesNotMakeItsCardTaller() {
+ final int[] heights = new int[2];
+
+ getInstrumentation().runOnMainSync(() -> {
+ final FrameLayout baseline = (FrameLayout) LayoutInflater.from(this.context)
+ .inflate(R.layout.maps_tag_card, null);
+ final FrameLayout withNearbyStatus = (FrameLayout) LayoutInflater.from(this.context)
+ .inflate(R.layout.maps_tag_card, null);
+
+ for (final FrameLayout card : new FrameLayout[]{baseline, withNearbyStatus}) {
+ ((TextView) card.findViewById(R.id.device_name)).setText("Keys");
+ ((TextView) card.findViewById(R.id.device_location)).setText(SHORT_ADDRESS);
+ // Inflated with a null root, so there is no parent-given LayoutParams to read
+ // back - unlike measureHeights, which sets these after row.addView(card).
+ card.setLayoutParams(new ViewGroup.LayoutParams(
+ CARD_WIDTH_PX, ViewGroup.LayoutParams.WRAP_CONTENT));
+ }
+
+ ((TextView) baseline.findViewById(R.id.device_last_update))
+ .setText("Last Updated: 2 minutes ago");
+ // "critical" (English) / "kritisch" (German) is the longest battery word; five
+ // filled dots is the longest signal reading; three digits covers up to the 30
+ // second freshness window in NearbyTagSightings with room to spare.
+ ((TextView) withNearbyStatus.findViewById(R.id.device_last_update))
+ .setText("Nearby (●●●●● · 999s) · Battery critical");
+
+ for (final FrameLayout card : new FrameLayout[]{baseline, withNearbyStatus}) {
+ card.measure(
+ View.MeasureSpec.makeMeasureSpec(CARD_WIDTH_PX, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
+ }
+ heights[0] = baseline.getMeasuredHeight();
+ heights[1] = withNearbyStatus.getMeasuredHeight();
+ });
+
+ assertEquals("the nearby status line wrapped and grew the card", heights[0], heights[1]);
+ }
+
/**
* Why the height has to be set at all.
*
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index e0781da5..e1184398 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -686,7 +686,7 @@ private void onTagHeardNearby(final NearbyTagSighting sighting) {
final FrameLayout card = this.dynamicCardsForTag.get(sighting.getBeaconId());
if (card != null) {
- this.showNearbyStatusOn(card, sighting);
+ this.showNearbyStatusOn(card, sighting, System.currentTimeMillis());
}
}
@@ -701,11 +701,16 @@ private void onTagHeardNearby(final NearbyTagSighting sighting) {
* The line goes back to the timestamp on its own once the sighting ages out, because
* nothing announces that a tag has left; we simply stop hearing it.
*/
- private void showNearbyStatusOn(final FrameLayout card, final NearbyTagSighting sighting) {
+ private void showNearbyStatusOn(
+ final FrameLayout card, final NearbyTagSighting sighting, final long nowMs) {
final TextView line = card.findViewById(R.id.device_last_update);
+ // Never negative: nowMs can be a hair behind seenAtMs when this runs right off the scan
+ // callback, before the clock the caller reads has ticked past it.
+ final long secondsAgo = Math.max(0, (nowMs - sighting.getSeenAtMs()) / 1000);
line.setText(this.getString(R.string.nearby_now_with_battery_and_signal,
this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())),
- NearbyTagLabel.signalStrengthBars(sighting.getRssi())));
+ NearbyTagLabel.signalStrengthBars(sighting.getRssi()),
+ secondsAgo));
}
@Override
@@ -2333,7 +2338,7 @@ private synchronized void updateBeaconCards() {
TextView deviceLastUpdate = v.findViewById(R.id.device_last_update);
final NearbyTagSighting heardNow = this.nearbySightings.freshFor(beaconId, now);
if (heardNow != null) {
- this.showNearbyStatusOn(v, heardNow);
+ this.showNearbyStatusOn(v, heardNow, now);
} else {
final var timeAgo = DateUtils.getRelativeTimeSpanString(
lastLocation.getTimestamp(),
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index ea634800..b572d19b 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -327,7 +327,7 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Klingelt!
In der Nähe
In der Nähe · Akku %1$s
- In der Nähe (Signal %2$s) · Akku %1$s
+ In der Nähe (%2$s · %3$ds) · Akku %1$s
Gerade per Bluetooth vom Tag selbst gelesen, nicht aus iCloud.
voll
mittel
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a3ad7274..55136134 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -359,7 +359,7 @@ You can set this up now, or any time later from Settings.
Ringing!
Nearby
Nearby · Battery %1$s
- Nearby (signal %2$s) · Battery %1$s
+ Nearby (%2$s · %3$ds) · Battery %1$s
Read from the tag over Bluetooth just now, not from iCloud.
full
medium
From 1206d4513f63d191d525058fedac218ca9cb4fe8 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 14:39:22 +0200
Subject: [PATCH 16/61] Tick the nearby cards once a second so "seconds ago"
actually counts
A sighting fires every one to three seconds while a tag is genuinely in
range, and onTagHeardNearby repainted the line on every one of them -
so it read close to 0s permanently, since the freshest sighting was
always the one just rendered. There was never a redraw in between two
sightings for the number to grow through.
Runs updateBeaconCards on a one-second interval for as long as the
nearby watch is active, alongside the sighting-driven redraw rather
than instead of it - reuses the same method that already handles both
the countable case and reverting an aged-out sighting back to the
Apple-network timestamp, rather than a second, narrower implementation
of the same rule.
---
.../android/opentagviewer/MapsActivity.java | 22 +++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index e1184398..75fb5231 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -80,6 +80,7 @@
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import dev.wander.android.opentagviewer.data.model.BeaconInformation;
@@ -230,6 +231,18 @@ public class MapsActivity extends AppCompatActivity implements IMapProvider.OnMa
/** The in-flight nearby scan, disposed in {@link #onPause()} so the radio stops with the screen. */
private Disposable nearbyWatchDisposable;
+ /**
+ * Redraws the cards once a second while the nearby scan is running, so a nearby card's
+ * "heard N seconds ago" keeps counting up between sightings instead of only changing when
+ * one arrives.
+ *
+ * A separate ticker rather than something {@link #onTagHeardNearby} drives, because a
+ * sighting fires roughly every one to three seconds while a tag is genuinely in range - so
+ * driving the redraw from sightings alone would repaint the line back to "0s" almost as
+ * often as it changed, and never show the gap growing in between.
+ */
+ private Disposable nearbyStatusTickerDisposable;
+
/** Location history plus the "can this be drawn" rule. See BeaconLocationHistoryTest. */
private final BeaconLocationHistory beaconLocations = new BeaconLocationHistory();
@@ -664,6 +677,10 @@ private void startWatchingForNearbyTags() {
.subscribe(
this::onTagHeardNearby,
error -> Log.w(TAG, "Nearby tag watch ended unexpectedly", error));
+
+ this.nearbyStatusTickerDisposable = Observable
+ .interval(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread())
+ .subscribe(tick -> this.updateBeaconCards());
}
private void stopWatchingForNearbyTags() {
@@ -671,6 +688,11 @@ private void stopWatchingForNearbyTags() {
this.nearbyWatchDisposable.dispose();
}
this.nearbyWatchDisposable = null;
+ if (this.nearbyStatusTickerDisposable != null
+ && !this.nearbyStatusTickerDisposable.isDisposed()) {
+ this.nearbyStatusTickerDisposable.dispose();
+ }
+ this.nearbyStatusTickerDisposable = null;
// Nothing on screen may go on claiming a tag is here once we have stopped listening.
this.nearbySightings.clear();
}
From ab6bf23eaa4962c6aaa411ce2becb2315994ca53 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 14:47:03 +0200
Subject: [PATCH 17/61] Replace the seconds counter with a lit pulse dot
The number read as noise next to the signal dots. A small dot beside
the line now lights (colorPrimary) for one second after a sighting
arrives and dims (colorOutlineVariant) again otherwise, giving the same
"is this actually live" read as a heartbeat rather than a count -
lighting and dimming roughly once per advertisement, since real gaps
between them run one to three seconds.
Real ImageView rather than another text character, so it reads as a
status light rather than more signal-dot clutter. Reuses the existing
circle_filled drawable (a plain oval meant to be tinted) instead of
adding a new asset. GONE once a sighting ages out and the line reverts
to the Apple-network timestamp, same as before.
---
.../ui/maps/TagCardLayoutTest.java | 10 ++--
.../android/opentagviewer/MapsActivity.java | 49 ++++++++++++++-----
app/src/main/res/layout/maps_tag_card.xml | 38 +++++++++++---
app/src/main/res/values-de/strings.xml | 2 +-
app/src/main/res/values/strings.xml | 2 +-
5 files changed, 76 insertions(+), 25 deletions(-)
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java
index aa261366..36fb4402 100644
--- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java
@@ -170,10 +170,10 @@ public void aLongDeviceNameDoesNotMakeItsCardTaller() {
}
/**
- * {@code MapsActivity.showNearbyStatusOn}'s longest realistic line - full signal, the
- * longest battery word, three-digit seconds - must not wrap to a second line and grow the
- * row. Built directly rather than through {@link #measureHeights}, which always writes a
- * fixed string to this field.
+ * {@code MapsActivity.showNearbyStatusOn}'s longest realistic line - full signal and the
+ * longest battery word - must not wrap to a second line and grow the row. Built directly
+ * rather than through {@link #measureHeights}, which always writes a fixed string to this
+ * field.
*/
@Test
public void theLongestNearbyStatusLineDoesNotMakeItsCardTaller() {
@@ -200,7 +200,7 @@ public void theLongestNearbyStatusLineDoesNotMakeItsCardTaller() {
// filled dots is the longest signal reading; three digits covers up to the 30
// second freshness window in NearbyTagSightings with room to spare.
((TextView) withNearbyStatus.findViewById(R.id.device_last_update))
- .setText("Nearby (●●●●● · 999s) · Battery critical");
+ .setText("Nearby (●●●●●) · Battery critical");
for (final FrameLayout card : new FrameLayout[]{baseline, withNearbyStatus}) {
card.measure(
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index 75fb5231..8505cc60 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -22,6 +22,7 @@
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
+import android.content.res.ColorStateList;
import android.location.Address;
import android.location.Geocoder;
import android.net.Uri;
@@ -62,6 +63,7 @@
import dev.wander.android.opentagviewer.ui.maps.MapPolyline;
import dev.wander.android.opentagviewer.ui.maps.MarkerPalette;
import com.google.android.libraries.places.api.Places;
+import com.google.android.material.color.MaterialColors;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.io.BufferedWriter;
@@ -713,26 +715,47 @@ private void onTagHeardNearby(final NearbyTagSighting sighting) {
}
/**
- * Replaces a card's "last updated" line while its tag is audible.
+ * How recently a sighting has to have arrived for {@link #showNearbyStatusOn} to light the
+ * pulse dot rather than dim it.
*
- *
The two say different things and the newer one wins: "last updated two hours ago"
- * describes when Apple's network last reported it, while a sighting means this phone can
- * hear it right now. Showing both would need a taller card, and the row is already measured
- * to the pixel - see {@code TagCardLayoutTest}.
+ *
Under the one-to-three-second gap between advertisements a tag in range genuinely
+ * produces, so the dot visibly lights and dims once per sighting instead of just staying lit
+ * - which is the live-activity read this is for, in place of a number that either sat at
+ * "0s" permanently (driven only by sightings) or needed a second ticker to mean anything.
+ */
+ private static final long PULSE_WINDOW_MS = 1_000L;
+
+ /**
+ * Replaces a card's "last updated" line while its tag is audible, and lights the pulse dot
+ * beside it if a sighting arrived within {@link #PULSE_WINDOW_MS}.
*
- *
The line goes back to the timestamp on its own once the sighting ages out, because
- * nothing announces that a tag has left; we simply stop hearing it.
+ *
The line says something different from "last updated two hours ago", which describes
+ * when Apple's network last reported it: a sighting means this phone can hear it right now.
+ * Showing both would need a taller card, and the row is already measured to the pixel - see
+ * {@code TagCardLayoutTest}.
+ *
+ *
The line - and the dot with it - go back to the timestamp on their own once the
+ * sighting ages out, because nothing announces that a tag has left; we simply stop hearing
+ * it. See the {@code else} branch in {@code updateBeaconCards} that calls this only when a
+ * fresh sighting exists.
*/
private void showNearbyStatusOn(
final FrameLayout card, final NearbyTagSighting sighting, final long nowMs) {
final TextView line = card.findViewById(R.id.device_last_update);
- // Never negative: nowMs can be a hair behind seenAtMs when this runs right off the scan
- // callback, before the clock the caller reads has ticked past it.
- final long secondsAgo = Math.max(0, (nowMs - sighting.getSeenAtMs()) / 1000);
line.setText(this.getString(R.string.nearby_now_with_battery_and_signal,
this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())),
- NearbyTagLabel.signalStrengthBars(sighting.getRssi()),
- secondsAgo));
+ NearbyTagLabel.signalStrengthBars(sighting.getRssi())));
+
+ // Never negative: nowMs can be a hair behind seenAtMs when this runs right off the scan
+ // callback, before the clock the caller reads has ticked past it.
+ final long msSinceSighting = Math.max(0, nowMs - sighting.getSeenAtMs());
+ final boolean pulsing = msSinceSighting < PULSE_WINDOW_MS;
+
+ final ImageView pulse = card.findViewById(R.id.device_nearby_pulse);
+ pulse.setVisibility(VISIBLE);
+ pulse.setImageTintList(ColorStateList.valueOf(MaterialColors.getColor(card, pulsing
+ ? com.google.android.material.R.attr.colorPrimary
+ : com.google.android.material.R.attr.colorOutlineVariant)));
}
@Override
@@ -2368,6 +2391,8 @@ private synchronized void updateBeaconCards() {
DateUtils.MINUTE_IN_MILLIS
).toString();
deviceLastUpdate.setText(this.getString(R.string.last_updated_x, timeAgo));
+ // Nothing live to pulse for once the sighting has aged out.
+ ((ImageView) v.findViewById(R.id.device_nearby_pulse)).setVisibility(GONE);
}
// **Put an existing card where it now belongs.** Cards are created once and reused,
diff --git a/app/src/main/res/layout/maps_tag_card.xml b/app/src/main/res/layout/maps_tag_card.xml
index 00bdb8f3..07ecf4cf 100644
--- a/app/src/main/res/layout/maps_tag_card.xml
+++ b/app/src/main/res/layout/maps_tag_card.xml
@@ -125,14 +125,40 @@
-
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+
+
+
+
+
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index b572d19b..01a81821 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -327,7 +327,7 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Klingelt!
In der Nähe
In der Nähe · Akku %1$s
- In der Nähe (%2$s · %3$ds) · Akku %1$s
+ In der Nähe (%2$s) · Akku %1$s
Gerade per Bluetooth vom Tag selbst gelesen, nicht aus iCloud.
voll
mittel
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 55136134..d0e77e02 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -359,7 +359,7 @@ You can set this up now, or any time later from Settings.
Ringing!
Nearby
Nearby · Battery %1$s
- Nearby (%2$s · %3$ds) · Battery %1$s
+ Nearby (%2$s) · Battery %1$s
Read from the tag over Bluetooth just now, not from iCloud.
full
medium
From cdc0c65787b50f723b1c9e0ddebe674a8a267175 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 14:54:18 +0200
Subject: [PATCH 18/61] Feed passive sightings into alignment self-correction
too
recordAccessorySeen's primary-key-only fix only ever ran from the ring
button's explicit scan (BleAccessorySoundTrigger). The passive watch
behind the map card and the device info screen's live battery row -
which sees a tag far more often, whenever the screen is simply open -
had no path back to it at all, so a tag never rung stayed exposed to
the exact drift the fix exists to correct, and could disappear from
its own currentMacAddresses margin with nothing failing anywhere to
say why. Two real keychains reproduced this: one stopped being found
nearby while sitting right next to the phone, the other kept working.
NearbyTagWatcher.SightingListener reports a matched sighting's beacon
and address, throttled to once a minute per beacon so a tag advertising
every one to three seconds does not start a Python interpreter that
often. Both activities wire it to the same BeaconRepository
#recordAccessorySighting the ring button already uses, so there is one
alignment-correction path, not two.
---
.../opentagviewer/DeviceInfoActivity.java | 15 +-
.../android/opentagviewer/MapsActivity.java | 20 ++-
.../opentagviewer/ble/NearbyTagWatcher.java | 72 +++++++++-
.../ble/NearbyTagWatcherTest.java | 128 ++++++++++++++++++
4 files changed, 230 insertions(+), 5 deletions(-)
create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcherTest.java
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index db695599..89fc2d87 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -637,7 +637,8 @@ private void startWatchingForThisTag() {
return;
}
- this.nearbyWatchDisposable = new NearbyTagWatcher(AppDependencies.accessoryMacResolver())
+ this.nearbyWatchDisposable = new NearbyTagWatcher(
+ AppDependencies.accessoryMacResolver(), this::correctAlignmentFromSighting)
.watch(this.getApplicationContext(), Map.of(this.beaconId, accessoryJson))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
@@ -645,6 +646,18 @@ private void startWatchingForThisTag() {
error -> Log.w(TAG, "Nearby watch ended for beaconId=" + this.beaconId, error));
}
+ /**
+ * Feeds a passive sighting back into alignment self-correction, the same way the ring
+ * button's explicit scan already does - see {@code NearbyTagWatcher.SightingListener}.
+ */
+ private void correctAlignmentFromSighting(
+ final String beaconId, final String mac, final long seenAtMs) {
+ this.beaconRepo.recordAccessorySighting(beaconId, mac, seenAtMs)
+ .subscribe(() -> { }, error -> Log.w(TAG,
+ "Failed to persist a self-corrected alignment for beaconId=" + beaconId,
+ error));
+ }
+
private void stopWatchingForThisTag() {
if (this.nearbyWatchDisposable != null && !this.nearbyWatchDisposable.isDisposed()) {
this.nearbyWatchDisposable.dispose();
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index 8505cc60..8434e713 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -673,7 +673,8 @@ private void startWatchingForNearbyTags() {
return;
}
- this.nearbyWatchDisposable = new NearbyTagWatcher(AppDependencies.accessoryMacResolver())
+ this.nearbyWatchDisposable = new NearbyTagWatcher(
+ AppDependencies.accessoryMacResolver(), this::correctAlignmentFromSighting)
.watch(this.getApplicationContext(), accessoryJsonByBeaconId)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
@@ -685,6 +686,23 @@ private void startWatchingForNearbyTags() {
.subscribe(tick -> this.updateBeaconCards());
}
+ /**
+ * Feeds a passive sighting back into alignment self-correction, the same way the ring
+ * button's explicit scan already does - see {@code NearbyTagWatcher.SightingListener}.
+ *
+ * Without this, a tag nobody has rung recently only gets a chance to correct its
+ * alignment when the periodic Apple-network fetch runs, and can silently drift out of
+ * {@code currentMacAddresses}' margin in between - which reads as "stopped being found
+ * nearby" for no visible reason, on a tag sitting right next to the phone.
+ */
+ private void correctAlignmentFromSighting(
+ final String beaconId, final String mac, final long seenAtMs) {
+ this.beaconRepo.recordAccessorySighting(beaconId, mac, seenAtMs)
+ .subscribe(() -> { }, error -> Log.w(TAG,
+ "Failed to persist a self-corrected alignment for beaconId=" + beaconId,
+ error));
+ }
+
private void stopWatchingForNearbyTags() {
if (this.nearbyWatchDisposable != null && !this.nearbyWatchDisposable.isDisposed()) {
this.nearbyWatchDisposable.dispose();
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
index a6b304b9..aa0a3193 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
@@ -13,7 +13,9 @@
import androidx.annotation.Nullable;
+import java.util.HashMap;
import java.util.Map;
+import java.util.concurrent.TimeUnit;
import dev.wander.android.opentagviewer.python.AccessoryMacResolver;
import io.reactivex.rxjava3.core.Observable;
@@ -46,17 +48,55 @@ interface Clock {
long nowMs();
}
+ /**
+ * Told, off the scan callback thread and throttled, when a sighting matches one of the
+ * caller's own tags - so a passive scan can feed alignment self-correction the same way the
+ * ring button's explicit scan does. Real: {@code BeaconRepository#recordAccessorySighting}.
+ *
+ *
Without this, a tag only ever heard through this class - never rung, and refreshed by
+ * the periodic Apple-network fetch only as often as that runs - has no way to correct a
+ * stored alignment that has drifted since the last fetch. It stays inside
+ * {@code currentMacAddresses}' 12 hour margin for a while and then, once the drift exceeds
+ * that, simply stops being found - with nothing failing anywhere to say why.
+ */
+ public interface SightingListener {
+ void onSighting(String beaconId, String mac, long seenAtMs);
+ }
+
+ /**
+ * How often {@link SightingListener#onSighting} fires for the same beacon.
+ *
+ *
A tag in range advertises every one to three seconds, and each one is a candidate
+ * correction - reporting every single one would start a Python interpreter that often. A
+ * correction that already matches the stored alignment is a no-op on the far side anyway,
+ * so nothing is lost by not attempting most of them.
+ */
+ static final long SIGHTING_LISTENER_INTERVAL_MS = TimeUnit.MINUTES.toMillis(1);
+
private final AccessoryMacResolver macResolver;
private final NearbyTagIndex index;
private final Clock clock;
+ @Nullable
+ private final SightingListener sightingListener;
+
+ private final Map lastListenerCallMs = new HashMap<>();
+
public NearbyTagWatcher(final AccessoryMacResolver macResolver) {
- this(macResolver, new NearbyTagIndex(), System::currentTimeMillis);
+ this(macResolver, null);
}
- NearbyTagWatcher(final AccessoryMacResolver macResolver, final NearbyTagIndex index,
+ public NearbyTagWatcher(
+ final AccessoryMacResolver macResolver, @Nullable final SightingListener listener) {
+ this(macResolver, listener, new NearbyTagIndex(), System::currentTimeMillis);
+ }
+
+ NearbyTagWatcher(final AccessoryMacResolver macResolver,
+ @Nullable final SightingListener sightingListener,
+ final NearbyTagIndex index,
final Clock clock) {
this.macResolver = macResolver;
+ this.sightingListener = sightingListener;
this.index = index;
this.clock = clock;
}
@@ -112,9 +152,14 @@ public Observable watch(
@Override
public void onScanResult(final int callbackType, final ScanResult result) {
final NearbyTagSighting sighting = sightingFrom(result);
- if (sighting != null && !emitter.isDisposed()) {
+ if (sighting == null) {
+ return;
+ }
+ if (!emitter.isDisposed()) {
emitter.onNext(sighting);
}
+ maybeNotifySightingListener(
+ sighting.getBeaconId(), result.getDevice().getAddress());
}
@Override
@@ -169,4 +214,25 @@ NearbyTagSighting sightingFrom(final ScanResult result) {
return new NearbyTagSighting(beaconId, result.getRssi(), advertisement.getBatteryLevel(),
advertisement.getState(), this.clock.nowMs());
}
+
+ /**
+ * Calls {@link #sightingListener}, throttled per beacon, off the calling thread.
+ *
+ * Off-thread because the real listener persists to Room through a Python call - see the
+ * interface doc - and this runs from {@code onScanResult}, which must not block.
+ */
+ void maybeNotifySightingListener(final String beaconId, final String mac) {
+ if (this.sightingListener == null) {
+ return;
+ }
+ final long nowMs = this.clock.nowMs();
+ final Long lastCallMs = this.lastListenerCallMs.get(beaconId);
+ if (lastCallMs != null && nowMs - lastCallMs < SIGHTING_LISTENER_INTERVAL_MS) {
+ return;
+ }
+ this.lastListenerCallMs.put(beaconId, nowMs);
+
+ Schedulers.io().scheduleDirect(
+ () -> this.sightingListener.onSighting(beaconId, mac, nowMs));
+ }
}
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcherTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcherTest.java
new file mode 100644
index 00000000..d4e1aea7
--- /dev/null
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcherTest.java
@@ -0,0 +1,128 @@
+package dev.wander.android.opentagviewer.ble;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import org.junit.Test;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import dev.wander.android.opentagviewer.python.AccessoryMacResolver;
+
+/**
+ * Covers {@link NearbyTagWatcher#maybeNotifySightingListener} through the package-private
+ * constructor - no radio, no Android, an injected clock. The scan itself needs a real adapter
+ * and is not exercised here; see {@link NearbyTagWatcher#sightingFrom} for what a JVM test can
+ * reach of the scan side.
+ *
+ *
The listener always fires on {@code Schedulers.io()}, deliberately - see the method's own
+ * doc - so every test here waits on a latch rather than asserting immediately after the call.
+ */
+public class NearbyTagWatcherTest {
+
+ private static final String BEACON_ID = "keys-beacon-id";
+ private static final String MAC = "AA:BB:CC:DD:EE:FF";
+ private static final long AWAIT_SECONDS = 5;
+
+ private static AccessoryMacResolver anyResolver() {
+ return json -> Map.of();
+ }
+
+ /** Records each call and counts down a latch, so a test can wait for the async dispatch. */
+ private static final class RecordingListener implements NearbyTagWatcher.SightingListener {
+ final List calls = new CopyOnWriteArrayList<>();
+ private final CountDownLatch latch;
+
+ RecordingListener(final int expectedCalls) {
+ this.latch = new CountDownLatch(expectedCalls);
+ }
+
+ @Override
+ public void onSighting(final String beaconId, final String mac, final long seenAtMs) {
+ this.calls.add(beaconId);
+ this.latch.countDown();
+ }
+
+ /** Waits for the expected call count, then gives a little more time to catch extras. */
+ void awaitThenSettle() throws InterruptedException {
+ if (!this.latch.await(AWAIT_SECONDS, TimeUnit.SECONDS)) {
+ fail("expected call(s) never arrived within " + AWAIT_SECONDS + "s");
+ }
+ Thread.sleep(100);
+ }
+ }
+
+ private static NearbyTagWatcher watcherWith(
+ final NearbyTagWatcher.SightingListener listener, final long[] clockMs) {
+ return new NearbyTagWatcher(
+ anyResolver(), listener, new NearbyTagIndex(), () -> clockMs[0]);
+ }
+
+ @Test
+ public void notifiesTheListenerOnAMatchedSighting() throws InterruptedException {
+ final RecordingListener listener = new RecordingListener(1);
+ final long[] clock = {0L};
+ final NearbyTagWatcher watcher = watcherWith(listener, clock);
+
+ watcher.maybeNotifySightingListener(BEACON_ID, MAC);
+
+ listener.awaitThenSettle();
+ assertEquals(1, listener.calls.size());
+ }
+
+ @Test
+ public void throttlesRepeatedCallsForTheSameBeacon() throws InterruptedException {
+ final RecordingListener listener = new RecordingListener(1);
+ final long[] clock = {0L};
+ final NearbyTagWatcher watcher = watcherWith(listener, clock);
+
+ watcher.maybeNotifySightingListener(BEACON_ID, MAC);
+ clock[0] = NearbyTagWatcher.SIGHTING_LISTENER_INTERVAL_MS - 1;
+ watcher.maybeNotifySightingListener(BEACON_ID, MAC);
+
+ listener.awaitThenSettle();
+ assertEquals("the second call landed inside the throttle window", 1, listener.calls.size());
+ }
+
+ @Test
+ public void callsAgainOnceTheThrottleWindowHasPassed() throws InterruptedException {
+ final RecordingListener listener = new RecordingListener(2);
+ final long[] clock = {0L};
+ final NearbyTagWatcher watcher = watcherWith(listener, clock);
+
+ watcher.maybeNotifySightingListener(BEACON_ID, MAC);
+ clock[0] = NearbyTagWatcher.SIGHTING_LISTENER_INTERVAL_MS;
+ watcher.maybeNotifySightingListener(BEACON_ID, MAC);
+
+ listener.awaitThenSettle();
+ assertEquals(2, listener.calls.size());
+ }
+
+ @Test
+ public void aNullListenerIsSimplySkipped() {
+ final NearbyTagWatcher watcher = new NearbyTagWatcher(
+ anyResolver(), null, new NearbyTagIndex(), () -> 0L);
+
+ // Must not throw.
+ watcher.maybeNotifySightingListener(BEACON_ID, MAC);
+ }
+
+ @Test
+ public void eachBeaconIsThrottledIndependently() throws InterruptedException {
+ final RecordingListener listener = new RecordingListener(2);
+ final long[] clock = {0L};
+ final NearbyTagWatcher watcher = watcherWith(listener, clock);
+
+ watcher.maybeNotifySightingListener(BEACON_ID, MAC);
+ watcher.maybeNotifySightingListener("bike-beacon-id", "11:22:33:44:55:66");
+
+ listener.awaitThenSettle();
+ assertTrue("a busy tag must not starve another tag's correction",
+ listener.calls.contains(BEACON_ID) && listener.calls.contains("bike-beacon-id"));
+ }
+}
From c2c339c840253a6591cef1cbe323ca20f0f97834 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 15:06:35 +0200
Subject: [PATCH 19/61] Reread beacons and restart the watch after a
self-correction lands
recordAccessorySighting writes the corrected alignment straight into
accessory_json in Room. Neither screen's in-memory model - beacons on
the map, beaconData on the device info screen - ever picked that up on
its own: both are only refreshed by loadEverything or the periodic
network fetch, and a passive BLE correction triggers neither. The
already-running NearbyTagWatcher kept matching against the stale
alignment its NearbyTagIndex was built from until one of those
unrelated refreshes happened to run - which measured in minutes, not
the few-second gaps a low-power scan alone would explain, and looked
like the tag had simply stopped being found nearby with nothing
failing anywhere to say why.
Both screens now follow the correction with a reread - getAllBeacons on
the map, getById on the device info screen, the same calls
loadEverything and the rename flow already use - and restart the watch
from the result, so the very next scan uses the address the correction
just landed on rather than the one before it.
---
.../opentagviewer/DeviceInfoActivity.java | 23 +++++++++++++--
.../android/opentagviewer/MapsActivity.java | 28 +++++++++++++++++--
2 files changed, 45 insertions(+), 6 deletions(-)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index 89fc2d87..d2e20646 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -650,12 +650,29 @@ private void startWatchingForThisTag() {
* Feeds a passive sighting back into alignment self-correction, the same way the ring
* button's explicit scan already does - see {@code NearbyTagWatcher.SightingListener}.
*/
+ /**
+ * Feeds a passive sighting back into alignment self-correction - see
+ * {@code NearbyTagWatcher.SightingListener} - and, if it changed anything, rereads {@link
+ * #beaconData} and restarts the watch from it.
+ *
+ * The reread is what makes the correction worth anything this session - see
+ * {@code MapsActivity}'s copy of this method for why: {@code recordAccessorySighting}
+ * writes straight to Room and never touches {@link #beaconData} on its own.
+ */
private void correctAlignmentFromSighting(
final String beaconId, final String mac, final long seenAtMs) {
this.beaconRepo.recordAccessorySighting(beaconId, mac, seenAtMs)
- .subscribe(() -> { }, error -> Log.w(TAG,
- "Failed to persist a self-corrected alignment for beaconId=" + beaconId,
- error));
+ .andThen(this.beaconRepo.getById(beaconId).firstOrError())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ reread -> {
+ this.beaconData = reread;
+ this.beaconInformation = BeaconDataParser.parse(List.of(reread)).get(0);
+ this.startWatchingForThisTag();
+ },
+ error -> Log.w(TAG,
+ "Failed to persist a self-corrected alignment for beaconId="
+ + beaconId, error));
}
private void stopWatchingForThisTag() {
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index 8434e713..43f49cda 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -138,6 +138,7 @@
import dev.wander.android.opentagviewer.ble.NearbyTagLabel;
import dev.wander.android.opentagviewer.ble.NearbyTagSighting;
import dev.wander.android.opentagviewer.ble.NearbyTagSightings;
+import dev.wander.android.opentagviewer.ble.NearbyTagIndex;
import dev.wander.android.opentagviewer.ble.NearbyTagWatcher;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Completable;
@@ -695,12 +696,33 @@ private void startWatchingForNearbyTags() {
* {@code currentMacAddresses}' margin in between - which reads as "stopped being found
* nearby" for no visible reason, on a tag sitting right next to the phone.
*/
+ /**
+ * Feeds a passive sighting back into alignment self-correction - see
+ * {@code NearbyTagWatcher.SightingListener} - and, if it changed anything, re-reads {@link
+ * #beacons} and restarts the nearby watch from it.
+ *
+ *
The reread is what makes the correction worth anything this session.
+ * {@code recordAccessorySighting} writes straight to {@code accessory_json} in Room; it does
+ * not touch {@link #beacons}, which is only ever refreshed by {@code loadEverything} or the
+ * periodic network fetch - neither of which this triggers. Without rereading here, the
+ * already-running {@link NearbyTagWatcher} keeps matching against the stale alignment its
+ * {@link NearbyTagIndex} was built from until one of those unrelated refreshes happens to
+ * run, which can be minutes away - a tag whose alignment just healed would still read as
+ * out of range for however long that takes.
+ */
private void correctAlignmentFromSighting(
final String beaconId, final String mac, final long seenAtMs) {
this.beaconRepo.recordAccessorySighting(beaconId, mac, seenAtMs)
- .subscribe(() -> { }, error -> Log.w(TAG,
- "Failed to persist a self-corrected alignment for beaconId=" + beaconId,
- error));
+ .andThen(this.beaconRepo.getAllBeacons().flatMap(BeaconDataParser::parseAsync))
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ allBeaconInformation -> {
+ this.addBeaconToCurrent(allBeaconInformation);
+ this.startWatchingForNearbyTags();
+ },
+ error -> Log.w(TAG,
+ "Failed to persist a self-corrected alignment for beaconId="
+ + beaconId, error));
}
private void stopWatchingForNearbyTags() {
From b0f536e066647a4906cd00935370018a87affd37 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 15:11:46 +0200
Subject: [PATCH 20/61] Revert the reread-and-restart from the previous commit
Broke both screens in real testing within minutes: addBeaconToCurrent
replaces every beacon's cached entry wholesale, which reset each
card's already-computed geocoding back to empty on every correction -
up to once a minute per tag - with nothing to refill it, so the map
fell back to showing raw coordinates and cards flickered through
"located for the first time" as their location-drawable state churned
with it. Restarting the whole watch on the same trigger cost the map's
already-heard sightings too, so the live badge dropped out along with
the address.
Back to persisting the correction only, same as the commit before
last. The staleness this session's watch is left with - it keeps
matching against the alignment it started with until an unrelated
loadEverything or periodic fetch happens to reread it - is real, but
a fix for it needs to patch just the corrected beacon's cached entry
and nudge only its own BLE candidate set, not the broad reread-and-
restart tried here.
---
.../opentagviewer/DeviceInfoActivity.java | 28 +++++----------
.../android/opentagviewer/MapsActivity.java | 36 +++++++------------
2 files changed, 22 insertions(+), 42 deletions(-)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index d2e20646..732e1d12 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -649,30 +649,20 @@ private void startWatchingForThisTag() {
/**
* Feeds a passive sighting back into alignment self-correction, the same way the ring
* button's explicit scan already does - see {@code NearbyTagWatcher.SightingListener}.
- */
- /**
- * Feeds a passive sighting back into alignment self-correction - see
- * {@code NearbyTagWatcher.SightingListener} - and, if it changed anything, rereads {@link
- * #beaconData} and restarts the watch from it.
*
- *
The reread is what makes the correction worth anything this session - see
- * {@code MapsActivity}'s copy of this method for why: {@code recordAccessorySighting}
- * writes straight to Room and never touches {@link #beaconData} on its own.
+ *
Persists only - deliberately does not reread {@link #beaconData} or restart the
+ * watch afterward. See {@code MapsActivity}'s copy of this method for why a first
+ * attempt at that was reverted: the broader version it shared code with reset every card's
+ * geocoding on the map screen. This screen only ever watches one tag, so the same reread
+ * here would likely have been safe on its own, but the two were built and tested together
+ * and are reverted together until the narrower fix lands for both.
*/
private void correctAlignmentFromSighting(
final String beaconId, final String mac, final long seenAtMs) {
this.beaconRepo.recordAccessorySighting(beaconId, mac, seenAtMs)
- .andThen(this.beaconRepo.getById(beaconId).firstOrError())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(
- reread -> {
- this.beaconData = reread;
- this.beaconInformation = BeaconDataParser.parse(List.of(reread)).get(0);
- this.startWatchingForThisTag();
- },
- error -> Log.w(TAG,
- "Failed to persist a self-corrected alignment for beaconId="
- + beaconId, error));
+ .subscribe(() -> { }, error -> Log.w(TAG,
+ "Failed to persist a self-corrected alignment for beaconId=" + beaconId,
+ error));
}
private void stopWatchingForThisTag() {
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index 43f49cda..570c96d8 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -695,34 +695,24 @@ private void startWatchingForNearbyTags() {
* alignment when the periodic Apple-network fetch runs, and can silently drift out of
* {@code currentMacAddresses}' margin in between - which reads as "stopped being found
* nearby" for no visible reason, on a tag sitting right next to the phone.
- */
- /**
- * Feeds a passive sighting back into alignment self-correction - see
- * {@code NearbyTagWatcher.SightingListener} - and, if it changed anything, re-reads {@link
- * #beacons} and restarts the nearby watch from it.
*
- *
The reread is what makes the correction worth anything this session.
- * {@code recordAccessorySighting} writes straight to {@code accessory_json} in Room; it does
- * not touch {@link #beacons}, which is only ever refreshed by {@code loadEverything} or the
- * periodic network fetch - neither of which this triggers. Without rereading here, the
- * already-running {@link NearbyTagWatcher} keeps matching against the stale alignment its
- * {@link NearbyTagIndex} was built from until one of those unrelated refreshes happens to
- * run, which can be minutes away - a tag whose alignment just healed would still read as
- * out of range for however long that takes.
+ *
Persists only - deliberately does not reread {@link #beacons} or restart the watch
+ * afterward. A first attempt at that called {@link #addBeaconToCurrent}, which replaces
+ * every beacon's entry wholesale and reset every card's already-computed geocoding back to
+ * empty on every correction - up to once a minute per tag - with nothing to refill it, so
+ * cards fell back to raw coordinates and stayed there. This session's watch keeps matching
+ * against the alignment it started with until the next {@code loadEverything} or periodic
+ * fetch picks the correction up on its own; a tag whose alignment just healed can still read
+ * as out of range until then. Narrower plumbing - patching just this one beacon's cached
+ * entry, and nudging only its BLE candidate set rather than restarting the whole scan and
+ * losing every card's live badge with it - is still open.
*/
private void correctAlignmentFromSighting(
final String beaconId, final String mac, final long seenAtMs) {
this.beaconRepo.recordAccessorySighting(beaconId, mac, seenAtMs)
- .andThen(this.beaconRepo.getAllBeacons().flatMap(BeaconDataParser::parseAsync))
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(
- allBeaconInformation -> {
- this.addBeaconToCurrent(allBeaconInformation);
- this.startWatchingForNearbyTags();
- },
- error -> Log.w(TAG,
- "Failed to persist a self-corrected alignment for beaconId="
- + beaconId, error));
+ .subscribe(() -> { }, error -> Log.w(TAG,
+ "Failed to persist a self-corrected alignment for beaconId=" + beaconId,
+ error));
}
private void stopWatchingForNearbyTags() {
From 0279e3d880a83e1849611d5b383f1e9829b21479 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 15:20:09 +0200
Subject: [PATCH 21/61] Scan at SCAN_MODE_BALANCED instead of
SCAN_MODE_LOW_POWER
Low power's short scan window and multi-second sleep between them
produced exactly what it duty-cycles to: several of a tag's own
advertisements arriving in a burst whenever a window happened to line
up, then nothing for several seconds until the next one - correct, but
looked like a bug in the pulse dot.
Balanced trades some of that battery saving for a steadier read. The
justification low-power had - a display feature that can run for as
long as a screen is open shouldn't keep the radio on that aggressively
- still argues against low-latency, but undersells the case for
balanced: a person watching this screen is specifically looking for a
tag right now, the same reason the ring button's own scan already
justifies its higher power draw.
---
.../opentagviewer/ble/NearbyTagSightings.java | 10 +++++-----
.../opentagviewer/ble/NearbyTagWatcher.java | 15 ++++++++++-----
2 files changed, 15 insertions(+), 10 deletions(-)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSightings.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSightings.java
index 62391337..5e41e36e 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSightings.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSightings.java
@@ -22,11 +22,11 @@ public final class NearbyTagSightings {
/**
* How long a sighting is worth showing.
*
- *
A separated accessory advertises every second or two, but
- * {@code SCAN_MODE_LOW_POWER} only listens in short windows a few seconds apart, so gaps of
- * several seconds are normal for a tag sitting right next to the phone. This is generous
- * enough to ride those out and short enough that a tag carried away stops claiming to be
- * here within about half a minute.
+ *
A separated accessory advertises every second or two, but even
+ * {@code SCAN_MODE_BALANCED} - see {@code NearbyTagWatcher} - still duty-cycles rather than
+ * listening continuously, so gaps of a few seconds between sightings are normal for a tag
+ * sitting right next to the phone. This is generous enough to ride those out and short
+ * enough that a tag carried away stops claiming to be here within about half a minute.
*/
static final long FRESH_FOR_MS = TimeUnit.SECONDS.toMillis(30);
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
index aa0a3193..184deebb 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
@@ -35,10 +35,15 @@
* it needs to run when nobody is watching, and a locally-sourced position is a different claim
* from one Apple's network made, which the location history has no way to express today.
*
- *
{@code SCAN_MODE_LOW_POWER} rather than the low-latency mode
- * {@link NearbyAccessoryScanner} uses. That one runs for a few seconds after an explicit tap and
- * wants an answer now; this one runs for as long as a screen is open and only needs to notice a
- * tag within a few seconds.
+ *
{@code SCAN_MODE_BALANCED} - a middle ground between the low-latency mode
+ * {@link NearbyAccessoryScanner} uses and this class's own original {@code SCAN_MODE_LOW_POWER}.
+ * Low-power's short scan window and multi-second sleep between them meant several of a tag's
+ * own advertisements arrived in a burst whenever a window happened to line up, then nothing for
+ * several seconds until the next one - honest about what low-power scanning actually looks
+ * like, but a person watching this screen is specifically looking for a tag right now, the same
+ * reason {@link NearbyAccessoryScanner} justifies its own higher power draw. Still not
+ * low-latency: this runs for as long as a screen stays open rather than for a few bounded
+ * seconds after a tap, so it keeps some of the duty cycle low-latency forgoes entirely.
*/
public class NearbyTagWatcher {
private static final String TAG = NearbyTagWatcher.class.getSimpleName();
@@ -175,7 +180,7 @@ public void onScanFailed(final int errorCode) {
scanner.startScan(null,
new ScanSettings.Builder()
- .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
+ .setScanMode(ScanSettings.SCAN_MODE_BALANCED)
.build(),
callback);
From efee76063dc7bfe6402d9b6b0b1b5801032401b0 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 18:21:50 +0200
Subject: [PATCH 22/61] Fill the two nearby strings into every locale
nearby_now_with_battery_and_signal and live_battery_with_signal - the
two format strings the nearby feature actually renders - existed only
in the default locale and German, so eight locales fell back to mixed-
language lines and scripts/add_strings.py --check failed. Backfilled
through the script, as AGENTS.md requires; the check is green again
(292 strings across 9 translated locales).
---
app/src/main/res/values-en/strings.xml | 2 ++
app/src/main/res/values-fr/strings.xml | 2 ++
app/src/main/res/values-ja/strings.xml | 2 ++
app/src/main/res/values-ko/strings.xml | 2 ++
app/src/main/res/values-nl/strings.xml | 2 ++
app/src/main/res/values-ru/strings.xml | 2 ++
app/src/main/res/values-zh-rCN/strings.xml | 2 ++
app/src/main/res/values-zh-rTW/strings.xml | 2 ++
8 files changed, 16 insertions(+)
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index 1f7abfd8..bd64b5c7 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -333,4 +333,6 @@ You can set this up now, or any time later from Settings.
low
critical
Battery
+ Nearby (%2$s) · Battery %1$s
+ %1$s · signal %2$s
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index b0a93a21..de997dbb 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -333,4 +333,6 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.<
faible
critique
Batterie
+ À proximité (%2$s) · Batterie %1$s
+ %1$s · signal %2$s
\ No newline at end of file
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 170209d8..3e481dcd 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -333,4 +333,6 @@
少
危険
バッテリー
+ 近くにあります(%2$s)· 電池 %1$s
+ %1$s · 信号 %2$s
\ No newline at end of file
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index e5cd636b..0ec371b7 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -333,4 +333,6 @@
부족
위험
배터리
+ 근처에 있음 (%2$s) · 배터리 %1$s
+ %1$s · 신호 %2$s
\ No newline at end of file
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 2b85870c..27cde69d 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -333,4 +333,6 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen.
laag
kritiek
Batterij
+ In de buurt (%2$s) · Batterij %1$s
+ %1$s · signaal %2$s
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 5d273498..dc870c7a 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -333,4 +333,6 @@
низкий
критический
Батарея
+ Рядом (%2$s) · Батарея %1$s
+ %1$s · сигнал %2$s
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index e5273300..5844eb40 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -333,4 +333,6 @@
偏低
极低
电量
+ 在附近(%2$s)· 电量%1$s
+ %1$s · 信号%2$s
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 78040bc3..165294ea 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -333,4 +333,6 @@
偏低
極低
電量
+ 在附近(%2$s)· 電量%1$s
+ %1$s · 訊號%2$s
From 8bc6f2893476aaee8f4dabf83a8b4068f5c9bc98 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 18:21:50 +0200
Subject: [PATCH 23/61] Rebuild the nearby index mid-watch, and publish it
safely across threads
The index's 10 minute expiry was only ever consulted when watch()
subscribed, so a screen left open longer than the key rollover kept
matching against rolled-past addresses for as long as the subscription
lived - the tag next to the phone silently stopped appearing until an
onPause/onResume bounce built a fresh watcher. The scan callback now
checks staleness per scan result (two long compares) and hands a
rebuild to Schedulers.io behind a single-flight guard, matching against
the old index until the new one lands.
That also forces the thread question the old code skated over: rebuild
runs on an io thread while beaconIdFor runs on the Bluetooth callback
thread, and the map was a plain HashMap mutated in place with no
happens-before edge of its own. The index now swaps a volatile
reference to a freshly built map, so a reader sees either the old index
or the new one, never a half-built in-between. The watcher's throttle
map becomes a ConcurrentHashMap for the same reason.
---
.../opentagviewer/ble/NearbyTagIndex.java | 15 ++++--
.../opentagviewer/ble/NearbyTagWatcher.java | 51 ++++++++++++++++++-
2 files changed, 60 insertions(+), 6 deletions(-)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java
index 14f1e584..021b0b95 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java
@@ -27,6 +27,13 @@
*
*
No Android and no Bluetooth in here, so the expiry rule and the matching are covered by a
* JVM test; the clock is a parameter for the same reason.
+ *
+ *
Written and read on different threads. {@link #rebuild} runs on an Rx io thread
+ * (it is blocking Python), while {@link #beaconIdFor} runs on the Bluetooth stack's scan
+ * callback thread, once per advertisement of anything. Hence the volatile reference that is
+ * swapped whole rather than a map mutated in place: a reader sees either the old index or the
+ * new one, never a half-built or momentarily empty in-between - a race here would not crash,
+ * it would drop matches, which presents as "the tag is never nearby".
*/
public final class NearbyTagIndex {
@@ -39,8 +46,8 @@ public final class NearbyTagIndex {
*/
static final long MAX_AGE_MS = TimeUnit.MINUTES.toMillis(10);
- private final Map beaconIdByMac = new HashMap<>();
- private long builtAtMs = Long.MIN_VALUE;
+ private volatile Map beaconIdByMac = Map.of();
+ private volatile long builtAtMs = Long.MIN_VALUE;
/** True when this has never been built, or was built long enough ago to be doubted. */
public boolean isStale(final long nowMs) {
@@ -75,8 +82,8 @@ public void rebuild(
}
}
- this.beaconIdByMac.clear();
- this.beaconIdByMac.putAll(rebuilt);
+ // Swapped whole, not mutated in place - see the class doc on the reader thread.
+ this.beaconIdByMac = rebuilt;
this.builtAtMs = nowMs;
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
index 184deebb..8db81b34 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
@@ -13,9 +13,10 @@
import androidx.annotation.Nullable;
-import java.util.HashMap;
import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import dev.wander.android.opentagviewer.python.AccessoryMacResolver;
import io.reactivex.rxjava3.core.Observable;
@@ -85,7 +86,13 @@ public interface SightingListener {
@Nullable
private final SightingListener sightingListener;
- private final Map lastListenerCallMs = new HashMap<>();
+ /** Written and read on the Bluetooth scan callback thread, but also constructed and first
+ * touched elsewhere - concurrent map so there is no thread this is unsafe from. */
+ private final Map lastListenerCallMs = new ConcurrentHashMap<>();
+
+ /** Guards {@link #maybeRebuildIndex} so a stale index triggers one rebuild, not one per
+ * advertisement that arrives while the first is still running. */
+ private final AtomicBoolean indexRebuildInFlight = new AtomicBoolean(false);
public NearbyTagWatcher(final AccessoryMacResolver macResolver) {
this(macResolver, null);
@@ -156,6 +163,11 @@ public Observable watch(
final ScanCallback callback = new ScanCallback() {
@Override
public void onScanResult(final int callbackType, final ScanResult result) {
+ // Checked per scan result, of anything, not only our own tags: once the
+ // index is stale, our own tag's advertisements are exactly the ones that
+ // no longer match, so they cannot be the trigger.
+ maybeRebuildIndex(accessoryJsonByBeaconId);
+
final NearbyTagSighting sighting = sightingFrom(result);
if (sighting == null) {
return;
@@ -191,6 +203,41 @@ public void onScanFailed(final int errorCode) {
}).subscribeOn(Schedulers.io());
}
+ /**
+ * Rebuilds the index in the background once it has gone stale, mid-subscription.
+ *
+ * Without this, a watch outliving the key rollover goes quietly deaf. The index
+ * is checked and rebuilt when {@link #watch} subscribes, but a screen left open longer than
+ * {@link NearbyTagIndex#MAX_AGE_MS} used to keep matching against rolled-past addresses for
+ * as long as the subscription lived - the tag next to the phone simply stopped appearing,
+ * with nothing failing anywhere, until an onPause/onResume bounce built a fresh watcher.
+ * Exactly the failure mode the expiry rule exists to prevent, made unreachable by only
+ * consulting it once.
+ *
+ *
Cheap on the hot path: a stale check is two long compares, and the rebuild itself -
+ * blocking Python, one interpreter call per tag - is handed to {@link Schedulers#io()}
+ * behind a single-flight guard. Until it completes, matching continues against the old
+ * index, which can only miss what it would have missed anyway.
+ */
+ private void maybeRebuildIndex(final Map accessoryJsonByBeaconId) {
+ if (!this.index.isStale(this.clock.nowMs())) {
+ return;
+ }
+ if (!this.indexRebuildInFlight.compareAndSet(false, true)) {
+ return;
+ }
+ Schedulers.io().scheduleDirect(() -> {
+ try {
+ this.index.rebuild(accessoryJsonByBeaconId, this.macResolver, this.clock.nowMs());
+ Log.d(TAG, "Rebuilt the nearby index mid-watch: " + this.index.size()
+ + " candidate address(es) for " + accessoryJsonByBeaconId.size()
+ + " tag(s)");
+ } finally {
+ this.indexRebuildInFlight.set(false);
+ }
+ });
+ }
+
/**
* One scan result turned into a sighting, or null if it is not one of ours.
*
From 040dae5118e8e2f2c7eeaf762d20277db6e52eb4 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 18:21:50 +0200
Subject: [PATCH 24/61] Start the nearby watch once the beacons have actually
loaded
On a cold launch onResume ran before the asynchronous beacon load had
populated this.beacons, so startWatchingForNearbyTags returned on the
empty list - before even asking for the BLE permission - and nothing
retried once the tags arrived. The whole session had no pulse, no
Nearby line, and no passive alignment correction until the app was
backgrounded and reopened. addBeaconToCurrent now starts the watch when
it first has something to watch for, guarded on the disposable so the
periodic account refresh does not bounce a running scan.
The permission request also moves ahead of the empty-list check (a
first-run user is now asked at all) and fires at most once per activity
instance: the system dialog pauses the activity, so requesting from
every onResume re-prompted the instant the user denied - an inescapable
loop on Android 10 and below.
---
.../android/opentagviewer/MapsActivity.java | 63 +++++++++++++++----
1 file changed, 52 insertions(+), 11 deletions(-)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index 570c96d8..c5fd336d 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -246,6 +246,18 @@ public class MapsActivity extends AppCompatActivity implements IMapProvider.OnMa
*/
private Disposable nearbyStatusTickerDisposable;
+ /**
+ * Whether this activity has already asked for the BLE permission the nearby watch needs.
+ *
+ * Never reset for the life of the activity: the system dialog pauses this activity, so
+ * asking again from every {@code onResume} re-prompted the moment the user denied - an
+ * inescapable loop on Android 10 and below, and a silent auto-denied request burned on
+ * every resume above that. Granting from the dialog still takes effect immediately through
+ * {@code onRequestPermissionsResult}; a user who denied can still enable it later through
+ * the system settings, which resumes this activity and passes the granted check directly.
+ */
+ private boolean nearbyBlePermissionRequested;
+
/** Location history plus the "can this be drawn" rule. See BeaconLocationHistoryTest. */
private final BeaconLocationHistory beaconLocations = new BeaconLocationHistory();
@@ -652,6 +664,26 @@ protected void onStop() {
private void startWatchingForNearbyTags() {
this.stopWatchingForNearbyTags();
+ // Asked for here rather than left to the ring button: this scan is what feeds the
+ // battery/audible badge on every card, so it wants to be running as soon as the screen
+ // opens, not only once somebody has separately triggered a ring. Silently doing nothing
+ // without permission, as NearbyTagWatcher itself does, would just look like every tag
+ // is permanently out of range.
+ //
+ // Before the empty-list check, so a first launch - where the beacons have not loaded
+ // yet - still asks. And at most once per activity: the system dialog pauses this
+ // activity, so a request fired from every onResume re-prompted the instant the user
+ // denied, a loop with no way out on Android 10 and below.
+ if (!BlePermissions.granted(this)) {
+ if (!this.nearbyBlePermissionRequested) {
+ this.nearbyBlePermissionRequested = true;
+ Log.d(TAG, "Requesting BLE permission(s) to watch for nearby tags");
+ ActivityCompat.requestPermissions(
+ this, BlePermissions.required(), NEARBY_PERMISSION_REQUEST_CODE);
+ }
+ return;
+ }
+
final Map accessoryJsonByBeaconId = new HashMap<>();
for (final var entry : this.beacons.entrySet()) {
final String accessoryJson = entry.getValue().getInfo().getOwnedBeaconAccessoryJson();
@@ -660,17 +692,8 @@ private void startWatchingForNearbyTags() {
}
}
if (accessoryJsonByBeaconId.isEmpty()) {
- return;
- }
-
- // Asked for here rather than left to the ring button: this scan is what feeds the
- // battery/audible badge on every card, so it wants to be running as soon as the screen
- // opens, not only once somebody has separately triggered a ring. Silently doing nothing
- // without permission, as NearbyTagWatcher itself does, would just look like every tag
- // is permanently out of range.
- if (!BlePermissions.granted(this)) {
- Log.d(TAG, "Requesting BLE permission(s) to watch for nearby tags");
- ActivityCompat.requestPermissions(this, BlePermissions.required(), NEARBY_PERMISSION_REQUEST_CODE);
+ // Ordinary on a cold start: the beacons load asynchronously and are not here yet.
+ // addBeaconToCurrent starts the watch once they arrive - see there.
return;
}
@@ -2101,6 +2124,24 @@ private synchronized void addBeaconToCurrent(final List allBe
}
this.beacons.put(beaconId, new BeaconData(beacon, Collections.emptyList()));
});
+
+ // The nearby watch could not start from onResume on a cold launch: it reads
+ // this.beacons, which was still empty because this load runs asynchronously, and
+ // nothing retried once the tags arrived - so the whole session had no pulse, no
+ // Nearby line, and no passive alignment correction until the app was backgrounded
+ // and reopened. Started here, once, when there is finally something to watch for.
+ // Guarded on the disposable so the periodic account refresh, which also lands here,
+ // does not bounce a running scan - Android silently blocks an app that starts scans
+ // too often.
+ if (!newBeaconInformation.isEmpty() && this.nearbyWatchDisposable == null) {
+ // Re-checked on the main thread: this load finishes on a background thread, and by
+ // the time the post runs, onResume may have started the watch already.
+ this.runOnUiThread(() -> {
+ if (this.nearbyWatchDisposable == null && !this.isFinishing()) {
+ this.startWatchingForNearbyTags();
+ }
+ });
+ }
}
private synchronized void addBeaconLocationsToCurrent(final Map> newItems) {
From 2007951b96c725f2a02ba5673a9a46e1f5fa1a44 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 18:34:35 +0200
Subject: [PATCH 25/61] Address the remaining review findings on the nearby
feature
Four fixes that overlap in the same two activities, so they land
together rather than pretending to be separable:
Age the device screen's live battery row. Once a sighting had arrived,
the row claimed a just-taken Bluetooth reading indefinitely, hours
after the tag left earshot. Every sighting now restarts a timer on
NearbyTagSightings.FRESH_FOR_MS (made public: one answer to how long a
sighting may be presented as current, shared with the map card), and
the row hides when it fires with nothing newer.
Retry a scan that died mid-session. onScanFailed completes the stream
(Bluetooth off, or the platform's scan-start budget exceeded), and
neither screen handled completion: the map's once-per-second ticker
kept redrawing every card for a scan that could produce nothing, and
the feature stayed dead for the session even after Bluetooth returned.
Both screens now stop their timers, drop stale badges, and retry every
30 seconds - far under the platform's scan budget, self-healing when
the radio returns. Disposal skips onComplete, so ordinary onPause
teardown does not trigger it.
Say when the nearby permission was refused. The refusal also keeps the
ring button hidden, and the request fires once per activity - without a
toast, someone who tapped Deny was left with no visible trace that
nearby and ringing exist, and no in-app path back short of the system
settings.
Hold the sighting-persistence policy in one class. It lived in four
hand-copied methods across the two activities, which this branch
already had to edit in lockstep once. AccessorySightingPersister now
owns both entry points (passive watcher sightings, ring-proven
sightings); the activities delegate.
---
.../AccessorySightingPersister.java | 62 ++++++++++++
.../opentagviewer/DeviceInfoActivity.java | 96 +++++++++++--------
.../android/opentagviewer/MapsActivity.java | 91 ++++++++----------
.../opentagviewer/ble/NearbyTagSightings.java | 6 +-
4 files changed, 161 insertions(+), 94 deletions(-)
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/AccessorySightingPersister.java
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/AccessorySightingPersister.java b/app/src/main/java/dev/wander/android/opentagviewer/AccessorySightingPersister.java
new file mode 100644
index 00000000..bebb6ae4
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/AccessorySightingPersister.java
@@ -0,0 +1,62 @@
+package dev.wander.android.opentagviewer;
+
+import android.util.Log;
+
+import dev.wander.android.opentagviewer.ble.BleSoundTriggerPhase;
+import dev.wander.android.opentagviewer.ble.BleSoundTriggerUpdate;
+import dev.wander.android.opentagviewer.db.repo.BeaconRepository;
+
+/**
+ * The one place a Bluetooth sighting is fed back into alignment self-correction, for both
+ * screens and both kinds of sighting.
+ *
+ * One class because the policy used to live in four hand-copied methods - a
+ * {@code correctAlignmentFromSighting} and a {@code keepWhatTheSightingProved} in each of
+ * {@code MapsActivity} and {@code DeviceInfoActivity} - and a change to how sightings are
+ * persisted already had to be applied to all four in lockstep once. Missing one would have
+ * silently diverged alignment self-correction between the map and the device screen.
+ *
+ *
Persists only - deliberately no reread of the screen's model and no watch restart.
+ * A first attempt at that reset every card's already-computed geocoding on the map on every
+ * correction, with nothing to refill it. The running session keeps matching against the
+ * alignment it started with until the next load or periodic fetch picks the correction up;
+ * a narrower per-beacon patch is still open.
+ *
+ *
Failure is logged and swallowed: a sighting that cannot be persisted costs the next scan
+ * a wider search, nothing else, and it must never turn a successful ring into an error.
+ */
+final class AccessorySightingPersister {
+ private static final String TAG = AccessorySightingPersister.class.getSimpleName();
+
+ private final BeaconRepository beaconRepo;
+
+ AccessorySightingPersister(final BeaconRepository beaconRepo) {
+ this.beaconRepo = beaconRepo;
+ }
+
+ /**
+ * A passive sighting from a {@code NearbyTagWatcher} - shaped to be used directly as its
+ * {@code SightingListener}.
+ */
+ void onSighting(final String beaconId, final String mac, final long seenAtMs) {
+ this.persist(beaconId, mac, seenAtMs);
+ }
+
+ /**
+ * A sighting proven by a ring attempt: the scan matched, whatever the GATT exchange did
+ * afterwards. Ignores progress updates and outcomes where nothing was found.
+ */
+ void keepWhatTheSightingProved(final String beaconId, final BleSoundTriggerUpdate update) {
+ if (update.getPhase() != BleSoundTriggerPhase.DONE
+ || update.getResult().getMatchedMac() == null) {
+ return;
+ }
+ this.persist(beaconId, update.getResult().getMatchedMac(), System.currentTimeMillis());
+ }
+
+ private void persist(final String beaconId, final String mac, final long seenAtMs) {
+ this.beaconRepo.recordAccessorySighting(beaconId, mac, seenAtMs)
+ .subscribe(() -> { }, error -> Log.w(TAG,
+ "Failed to persist a sighting for beaconId=" + beaconId, error));
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index 732e1d12..0661d88d 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -50,6 +50,7 @@
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.concurrent.TimeUnit;
import dev.wander.android.opentagviewer.ble.BlePermissions;
import dev.wander.android.opentagviewer.ble.BleSoundTriggerPhase;
@@ -57,6 +58,7 @@
import dev.wander.android.opentagviewer.ble.BleSoundTriggerUpdate;
import dev.wander.android.opentagviewer.ble.NearbyTagLabel;
import dev.wander.android.opentagviewer.ble.NearbyTagSighting;
+import dev.wander.android.opentagviewer.ble.NearbyTagSightings;
import dev.wander.android.opentagviewer.ble.NearbyTagWatcher;
import dev.wander.android.opentagviewer.data.model.BeaconInformation;
import dev.wander.android.opentagviewer.data.model.UserMapCameraPosition;
@@ -154,6 +156,23 @@ public class DeviceInfoActivity extends AppCompatActivity
*/
private Disposable nearbyWatchDisposable;
+ /**
+ * Hides the live battery row once its last sighting is too old to stand behind - reset by
+ * every new sighting, so the row only ages out when the tag has genuinely gone quiet.
+ *
+ *
Without this the row never aged at all: once a tag had been heard, "read from the tag
+ * just now" stayed on screen for hours after the tag left earshot - exactly the staleness
+ * the row exists to be free of. Same clock as the map card's badge:
+ * {@link NearbyTagSightings#FRESH_FOR_MS}.
+ */
+ private Disposable liveBatteryExpiry;
+
+ /** The pending retry after the scan died mid-session - see {@link #onNearbyWatchEnded}. */
+ private Disposable nearbyWatchRetryDisposable;
+
+ /** The one place a Bluetooth sighting is persisted - see {@link AccessorySightingPersister}. */
+ private AccessorySightingPersister sightingPersister;
+
private boolean hasNameChanges = false;
@Override
@@ -177,6 +196,7 @@ protected void onCreate(Bundle savedInstanceState) {
this.beaconRepo = new BeaconRepository(
OpenTagViewerDatabase.getInstance(getApplicationContext()));
+ this.sightingPersister = new AccessorySightingPersister(this.beaconRepo);
this.beaconData = this.beaconRepo.getById(this.beaconId).blockingFirst();
this.beaconInformation = BeaconDataParser.parse(List.of(this.beaconData)).get(0);
@@ -638,31 +658,27 @@ private void startWatchingForThisTag() {
}
this.nearbyWatchDisposable = new NearbyTagWatcher(
- AppDependencies.accessoryMacResolver(), this::correctAlignmentFromSighting)
+ AppDependencies.accessoryMacResolver(), this.sightingPersister::onSighting)
.watch(this.getApplicationContext(), Map.of(this.beaconId, accessoryJson))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
this::showLiveBattery,
- error -> Log.w(TAG, "Nearby watch ended for beaconId=" + this.beaconId, error));
+ error -> Log.w(TAG, "Nearby watch ended for beaconId=" + this.beaconId, error),
+ this::onNearbyWatchEnded);
}
/**
- * Feeds a passive sighting back into alignment self-correction, the same way the ring
- * button's explicit scan already does - see {@code NearbyTagWatcher.SightingListener}.
- *
- *
Persists only - deliberately does not reread {@link #beaconData} or restart the
- * watch afterward. See {@code MapsActivity}'s copy of this method for why a first
- * attempt at that was reverted: the broader version it shared code with reset every card's
- * geocoding on the map screen. This screen only ever watches one tag, so the same reread
- * here would likely have been safe on its own, but the two were built and tested together
- * and are reverted together until the narrower fix lands for both.
+ * The scan died mid-session (Bluetooth off, or the platform refused the scan) rather than
+ * being stopped - disposal skips onComplete. Retried every 30 seconds so the live battery
+ * row comes back on its own when the radio does; see {@code MapsActivity}'s twin for the
+ * budget reasoning.
*/
- private void correctAlignmentFromSighting(
- final String beaconId, final String mac, final long seenAtMs) {
- this.beaconRepo.recordAccessorySighting(beaconId, mac, seenAtMs)
- .subscribe(() -> { }, error -> Log.w(TAG,
- "Failed to persist a self-corrected alignment for beaconId=" + beaconId,
- error));
+ private void onNearbyWatchEnded() {
+ Log.i(TAG, "Nearby watch ended mid-session for beaconId=" + this.beaconId
+ + "; retrying in 30s");
+ this.nearbyWatchRetryDisposable = Observable
+ .timer(30, TimeUnit.SECONDS, AndroidSchedulers.mainThread())
+ .subscribe(tick -> this.startWatchingForThisTag());
}
private void stopWatchingForThisTag() {
@@ -670,6 +686,15 @@ private void stopWatchingForThisTag() {
this.nearbyWatchDisposable.dispose();
}
this.nearbyWatchDisposable = null;
+ if (this.liveBatteryExpiry != null && !this.liveBatteryExpiry.isDisposed()) {
+ this.liveBatteryExpiry.dispose();
+ }
+ this.liveBatteryExpiry = null;
+ if (this.nearbyWatchRetryDisposable != null
+ && !this.nearbyWatchRetryDisposable.isDisposed()) {
+ this.nearbyWatchRetryDisposable.dispose();
+ }
+ this.nearbyWatchRetryDisposable = null;
}
/**
@@ -685,6 +710,17 @@ private void showLiveBattery(final NearbyTagSighting sighting) {
this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())),
NearbyTagLabel.signalStrengthBars(sighting.getRssi())));
this.findViewById(R.id.device_settings_live_battery).setVisibility(VISIBLE);
+
+ // Every sighting restarts the expiry, so the row hides only once the tag has been
+ // quiet for the whole window - see the field doc.
+ if (this.liveBatteryExpiry != null && !this.liveBatteryExpiry.isDisposed()) {
+ this.liveBatteryExpiry.dispose();
+ }
+ this.liveBatteryExpiry = Observable
+ .timer(NearbyTagSightings.FRESH_FOR_MS, TimeUnit.MILLISECONDS,
+ AndroidSchedulers.mainThread())
+ .subscribe(tick -> this.findViewById(R.id.device_settings_live_battery)
+ .setVisibility(GONE));
}
@Override
@@ -771,7 +807,7 @@ private void startPlaySoundNearby() {
* {@link #showPlaySoundStatus}) or the terminal outcome.
*/
private void handlePlaySoundUpdate(final BleSoundTriggerUpdate update) {
- this.keepWhatTheSightingProved(update);
+ this.sightingPersister.keepWhatTheSightingProved(this.beaconId, update);
if (update.getPhase() != BleSoundTriggerPhase.DONE) {
this.showPlaySoundStatus(phaseMessageRes(update.getPhase()), LENGTH_SHORT);
@@ -780,30 +816,6 @@ private void handlePlaySoundUpdate(final BleSoundTriggerUpdate update) {
this.showPlaySoundResult(update.getResult());
}
- /**
- * Keep the alignment a Bluetooth sighting proves, so the next scan is cheap.
- *
- *
Fire and forget: this runs after the sound has already played or failed, and the user
- * asked for a noise rather than for a database write. See
- * {@code BeaconRepository#recordAccessorySighting}.
- */
- private void keepWhatTheSightingProved(final BleSoundTriggerUpdate update) {
- if (update.getPhase() != BleSoundTriggerPhase.DONE
- || update.getResult().getMatchedMac() == null) {
- return;
- }
- this.beaconRepo.recordAccessorySighting(
- this.beaconId,
- update.getResult().getMatchedMac(),
- System.currentTimeMillis(),
- // No hint: the ring path knows which address answered but not which
- // index it came from - see BleSoundTriggerResult. Python falls back to
- // checking the whole window, which is what it did before hints existed.
- null)
- .subscribe(() -> { }, error ->
- Log.d(TAG, "Could not keep the alignment from a sighting", error));
- }
-
private static int phaseMessageRes(final BleSoundTriggerPhase phase) {
switch (phase) {
case CONNECTING:
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index c5fd336d..195a8aa8 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -258,6 +258,12 @@ public class MapsActivity extends AppCompatActivity implements IMapProvider.OnMa
*/
private boolean nearbyBlePermissionRequested;
+ /** The pending retry after the scan died mid-session - see {@link #onNearbyWatchEnded}. */
+ private Disposable nearbyWatchRetryDisposable;
+
+ /** The one place a Bluetooth sighting is persisted - see {@link AccessorySightingPersister}. */
+ private AccessorySightingPersister sightingPersister;
+
/** Location history plus the "can this be drawn" rule. See BeaconLocationHistoryTest. */
private final BeaconLocationHistory beaconLocations = new BeaconLocationHistory();
@@ -490,6 +496,7 @@ protected void onCreate(Bundle savedInstanceState) {
this.beaconRepo = new BeaconRepository(
OpenTagViewerDatabase.getInstance(getApplicationContext()));
+ this.sightingPersister = new AccessorySightingPersister(this.beaconRepo);
this.fusedLocationClient = LocationServices.getFusedLocationProviderClient(this);
@@ -698,12 +705,13 @@ private void startWatchingForNearbyTags() {
}
this.nearbyWatchDisposable = new NearbyTagWatcher(
- AppDependencies.accessoryMacResolver(), this::correctAlignmentFromSighting)
+ AppDependencies.accessoryMacResolver(), this.sightingPersister::onSighting)
.watch(this.getApplicationContext(), accessoryJsonByBeaconId)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
this::onTagHeardNearby,
- error -> Log.w(TAG, "Nearby tag watch ended unexpectedly", error));
+ error -> Log.w(TAG, "Nearby tag watch ended unexpectedly", error),
+ this::onNearbyWatchEnded);
this.nearbyStatusTickerDisposable = Observable
.interval(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread())
@@ -711,31 +719,26 @@ private void startWatchingForNearbyTags() {
}
/**
- * Feeds a passive sighting back into alignment self-correction, the same way the ring
- * button's explicit scan already does - see {@code NearbyTagWatcher.SightingListener}.
+ * The scan died mid-session rather than being stopped: Bluetooth toggled off, or the
+ * platform refused the scan (e.g. too many scan starts in a short window).
*
- *
Without this, a tag nobody has rung recently only gets a chance to correct its
- * alignment when the periodic Apple-network fetch runs, and can silently drift out of
- * {@code currentMacAddresses}' margin in between - which reads as "stopped being found
- * nearby" for no visible reason, on a tag sitting right next to the phone.
- *
- *
Persists only - deliberately does not reread {@link #beacons} or restart the watch
- * afterward. A first attempt at that called {@link #addBeaconToCurrent}, which replaces
- * every beacon's entry wholesale and reset every card's already-computed geocoding back to
- * empty on every correction - up to once a minute per tag - with nothing to refill it, so
- * cards fell back to raw coordinates and stayed there. This session's watch keeps matching
- * against the alignment it started with until the next {@code loadEverything} or periodic
- * fetch picks the correction up on its own; a tag whose alignment just healed can still read
- * as out of range until then. Narrower plumbing - patching just this one beacon's cached
- * entry, and nudging only its BLE candidate set rather than restarting the whole scan and
- * losing every card's live badge with it - is still open.
+ *
Cancellation does not come through here - disposing skips onComplete - so this only
+ * runs for genuine mid-session death. Two things then must not keep happening: the
+ * once-per-second ticker redrawing every card for a scan that can no longer produce
+ * sightings, and the badges claiming tags are here based on a radio nobody is listening
+ * to. And one thing must: a retry, or the nearby feature stays dead for the rest of the
+ * session even after Bluetooth comes back. One attempt per 30 seconds is far under the
+ * platform's scan-start budget, and each failed attempt completes again and reschedules,
+ * so it self-heals whenever the radio returns.
*/
- private void correctAlignmentFromSighting(
- final String beaconId, final String mac, final long seenAtMs) {
- this.beaconRepo.recordAccessorySighting(beaconId, mac, seenAtMs)
- .subscribe(() -> { }, error -> Log.w(TAG,
- "Failed to persist a self-corrected alignment for beaconId=" + beaconId,
- error));
+ private void onNearbyWatchEnded() {
+ Log.i(TAG, "The nearby tag watch ended mid-session; retrying in 30s");
+ this.stopWatchingForNearbyTags();
+ this.updateBeaconCards();
+
+ this.nearbyWatchRetryDisposable = Observable
+ .timer(30, TimeUnit.SECONDS, AndroidSchedulers.mainThread())
+ .subscribe(tick -> this.startWatchingForNearbyTags());
}
private void stopWatchingForNearbyTags() {
@@ -748,6 +751,11 @@ private void stopWatchingForNearbyTags() {
this.nearbyStatusTickerDisposable.dispose();
}
this.nearbyStatusTickerDisposable = null;
+ if (this.nearbyWatchRetryDisposable != null
+ && !this.nearbyWatchRetryDisposable.isDisposed()) {
+ this.nearbyWatchRetryDisposable.dispose();
+ }
+ this.nearbyWatchRetryDisposable = null;
// Nothing on screen may go on claiming a tag is here once we have stopped listening.
this.nearbySightings.clear();
}
@@ -1605,7 +1613,7 @@ private void handleContinuousPingUpdate(final String beaconId, final BleSoundTri
Log.d(TAG, "Continuous ping update for beaconId=" + beaconId + ": " + update.getPhase()
+ (update.getResult() == null ? "" : " (" + update.getResult().getStatus() + ")"));
- this.keepWhatTheSightingProved(beaconId, update);
+ this.sightingPersister.keepWhatTheSightingProved(beaconId, update);
// A card for a beaconId other than the one this loop is for stopped existing (e.g. the
// tag left the visible list) or continuous ping was stopped/switched to another tag
@@ -1660,31 +1668,6 @@ private void handleContinuousPingUpdate(final String beaconId, final BleSoundTri
TagCardHelper.setRingLoading(container, update.getPhase() != BleSoundTriggerPhase.DONE);
}
- /**
- * Keep the alignment a Bluetooth sighting proves, so the next cycle's scan is cheap.
- *
- *
Continuous ping rescans every few seconds, so this is the difference between deriving a
- * twelve-hour range over and over and deriving three keys. Fire and forget - see
- * {@code BeaconRepository#recordAccessorySighting}.
- */
- private void keepWhatTheSightingProved(
- final String beaconId, final BleSoundTriggerUpdate update) {
- if (update.getPhase() != BleSoundTriggerPhase.DONE
- || update.getResult().getMatchedMac() == null) {
- return;
- }
- this.beaconRepo.recordAccessorySighting(
- beaconId,
- update.getResult().getMatchedMac(),
- System.currentTimeMillis(),
- // No hint: the ring path knows which address answered but not which
- // index it came from - see BleSoundTriggerResult. Python falls back to
- // checking the whole window, which is what it did before hints existed.
- null)
- .subscribe(() -> { }, error ->
- Log.d(TAG, "Could not keep the alignment from a sighting", error));
- }
-
private void stopContinuousPing() {
if (this.continuousPingDisposable != null && !this.continuousPingDisposable.isDisposed()) {
this.continuousPingDisposable.dispose();
@@ -2810,6 +2793,12 @@ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permis
this.startWatchingForNearbyTags();
} else {
Log.i(TAG, "BLE permission refused; not watching for nearby tags");
+ // Said out loud because the refusal also keeps the ring button hidden (see
+ // updateBeaconCards), and the request fires only once per activity - so with
+ // no toast, someone who taps Deny is left with no visible trace that nearby
+ // and ringing exist, and no in-app path back to them short of the system
+ // settings.
+ Toast.makeText(this, R.string.play_sound_permission_denied, LENGTH_LONG).show();
}
// The ring button on every card was hidden while this was undecided - see
// updateBeaconCards - and needs to be shown or stay hidden depending on the answer.
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSightings.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSightings.java
index 5e41e36e..04232a53 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSightings.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSightings.java
@@ -27,8 +27,12 @@ public final class NearbyTagSightings {
* listening continuously, so gaps of a few seconds between sightings are normal for a tag
* sitting right next to the phone. This is generous enough to ride those out and short
* enough that a tag carried away stops claiming to be here within about half a minute.
+ *
+ *
Public because it is the one answer to "how long may a sighting be presented as
+ * current", wherever that presentation happens - the device info screen's live battery row
+ * ages out on the same clock rather than inventing a second one.
*/
- static final long FRESH_FOR_MS = TimeUnit.SECONDS.toMillis(30);
+ public static final long FRESH_FOR_MS = TimeUnit.SECONDS.toMillis(30);
private final Map latestByBeaconId = new ConcurrentHashMap<>();
From 21e7ac78863cc7a158e4fb835356b3cf07351cc4 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 19:04:49 +0200
Subject: [PATCH 26/61] Filter the nearby scan in hardware, and restart it
before Android mutes it
Two platform behaviors the watch was exposed to:
An unfiltered scan delivers every BLE frame of every device in earshot
to the callback - tens per second in an ordinary flat, nearly all of
them discarded by sightingFrom. The controller can do that discarding
itself: the scan now filters on Apple's company ID plus the
offline-finding type byte, which is exactly the check
FindMyAdvertisement.parse starts with, so nothing that would have
matched is lost and the callback fires only for Find My frames.
And any scan running longer than 30 minutes is silently downgraded to
SCAN_MODE_OPPORTUNISTIC, which only delivers results while some other
app happens to be scanning - a map screen left open for half an hour
went quietly deaf, the same presentation as every other failure this
class has had to chase. The scan is now stopped and restarted every 20
minutes, one start per 20 minutes against the platform's
5-per-30-seconds budget; if the restart itself fails (Bluetooth went
away in between) the stream completes so the caller's ordinary retry
takes over.
---
.../ble/FindMyAdvertisement.java | 6 +-
.../opentagviewer/ble/NearbyTagWatcher.java | 57 +++++++++++++++++--
2 files changed, 56 insertions(+), 7 deletions(-)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisement.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisement.java
index 35fc50f9..0f44c48b 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisement.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisement.java
@@ -30,8 +30,10 @@ public final class FindMyAdvertisement {
/** Apple's Bluetooth SIG company identifier. */
public static final int APPLE_COMPANY_ID = 0x004C;
- /** Apple's "offline finding" advertisement type, the first payload byte. */
- private static final byte TYPE_OFFLINE_FINDING = 0x12;
+ /** Apple's "offline finding" advertisement type, the first payload byte. Package-visible
+ * so {@link NearbyTagWatcher} can hand it to the hardware scan filter - the filter and this
+ * parser must agree on what a Find My frame is, so there is one constant, not two. */
+ static final byte TYPE_OFFLINE_FINDING = 0x12;
/** Payload length of the full beacon an accessory sends once separated from its owner. */
private static final byte LEN_SEPARATED = 0x19;
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
index 8db81b34..967ddbc5 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
@@ -5,6 +5,7 @@
import android.bluetooth.BluetoothManager;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanFilter;
import android.bluetooth.le.ScanRecord;
import android.bluetooth.le.ScanResult;
import android.bluetooth.le.ScanSettings;
@@ -13,6 +14,7 @@
import androidx.annotation.Nullable;
+import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
@@ -20,6 +22,7 @@
import dev.wander.android.opentagviewer.python.AccessoryMacResolver;
import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
@@ -190,19 +193,63 @@ public void onScanFailed(final int errorCode) {
}
};
- scanner.startScan(null,
- new ScanSettings.Builder()
- .setScanMode(ScanSettings.SCAN_MODE_BALANCED)
- .build(),
- callback);
+ // Filtered in hardware, not only in software. An unfiltered scan delivered every
+ // BLE frame of every device in earshot to the callback - tens per second in an
+ // ordinary flat, nearly all of them discarded by sightingFrom. The controller can
+ // do that discarding itself: Apple's company ID plus the offline-finding type byte
+ // is exactly the check FindMyAdvertisement.parse starts with, so nothing that would
+ // have matched is lost, and the callback now fires only for Find My frames.
+ final List findMyFramesOnly = List.of(new ScanFilter.Builder()
+ .setManufacturerData(FindMyAdvertisement.APPLE_COMPANY_ID,
+ new byte[]{FindMyAdvertisement.TYPE_OFFLINE_FINDING},
+ new byte[]{(byte) 0xFF})
+ .build());
+ final ScanSettings settings = new ScanSettings.Builder()
+ .setScanMode(ScanSettings.SCAN_MODE_BALANCED)
+ .build();
+
+ scanner.startScan(findMyFramesOnly, settings, callback);
+
+ // Restarted well before the platform's 30 minute mark: Android silently downgrades
+ // any scan running longer than that to SCAN_MODE_OPPORTUNISTIC, which only delivers
+ // results while some other app happens to be scanning - a screen left open for half
+ // an hour would go quietly deaf, the same presentation as every other failure this
+ // class has had to chase. One stop/start pair per 20 minutes is far inside the
+ // 5-starts-per-30-seconds budget.
+ final Disposable scanRefresh = Observable
+ .interval(SCAN_RESTART_INTERVAL_MS, SCAN_RESTART_INTERVAL_MS,
+ TimeUnit.MILLISECONDS, Schedulers.io())
+ .subscribe(tick -> {
+ try {
+ scanner.stopScan(callback);
+ scanner.startScan(findMyFramesOnly, settings, callback);
+ Log.d(TAG, "Restarted the nearby scan before the platform's "
+ + "long-scan downgrade");
+ } catch (final Exception e) {
+ // Bluetooth went away between the stop and the start. Complete, so
+ // the caller's ordinary retry takes over rather than this looking
+ // like a scan that is still running.
+ Log.w(TAG, "Could not restart the nearby scan", e);
+ if (!emitter.isDisposed()) {
+ emitter.onComplete();
+ }
+ }
+ });
emitter.setCancellable(() -> {
Log.d(TAG, "Stopped watching for nearby tags");
+ scanRefresh.dispose();
scanner.stopScan(callback);
});
}).subscribeOn(Schedulers.io());
}
+ /**
+ * How often the running scan is stopped and started again - under Android's 30 minute
+ * limit, past which a continuous scan is silently downgraded to opportunistic delivery.
+ */
+ static final long SCAN_RESTART_INTERVAL_MS = TimeUnit.MINUTES.toMillis(20);
+
/**
* Rebuilds the index in the background once it has gone stale, mid-subscription.
*
From 2ef5b89cfcb6e64d86962eb8e59ce6442a62f44a Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 22:01:39 +0200
Subject: [PATCH 27/61] Read only the address side of the resolved candidate
map
Fallout from rebasing onto upstream 1.1.0: currentMacAddresses returns
Map there (the play-sound work feeds the index back for
alignment), while NearbyTagIndex was still written against the List
this feature originally added. Take keySet() - the index only needs the
addresses, and which key index each came from is not its concern.
---
.../wander/android/opentagviewer/ble/NearbyTagIndex.java | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java
index 021b0b95..fb6be1cc 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java
@@ -3,7 +3,7 @@
import androidx.annotation.Nullable;
import java.util.HashMap;
-import java.util.List;
+import java.util.Set;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@@ -71,7 +71,9 @@ public void rebuild(
final Map rebuilt = new HashMap<>();
for (final Map.Entry entry : accessoryJsonByBeaconId.entrySet()) {
- final List macs = resolver.currentMacAddresses(entry.getValue());
+ // Only the address is wanted here; the key index each maps to is not this class's
+ // business - see AccessoryMacResolver#recordSeen on why only Python may act on it.
+ final Set macs = resolver.currentMacAddresses(entry.getValue()).keySet();
for (final String mac : macs) {
if (mac != null) {
// Upper-cased on the way in so lookups need no normalisation per scan
From fdbd53601f762eff10caa98bf36c4bda10eed938 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Sun, 23 Aug 2026 22:03:57 +0200
Subject: [PATCH 28/61] Match NearbyTagIndexTest to the Map-returning resolver
Same rebase fallout as the production class: the test's fake resolver
still answered List, which no longer conforms to the
Map currentMacAddresses returns on upstream 1.1.0. The
fake now returns a candidate map (indices are placeholders the index
never reads).
---
.../opentagviewer/ble/NearbyTagIndexTest.java | 31 ++++++++++++-------
1 file changed, 20 insertions(+), 11 deletions(-)
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexTest.java
index ef1fb508..1abf1984 100644
--- a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexTest.java
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexTest.java
@@ -8,7 +8,6 @@
import org.junit.Test;
import java.util.HashMap;
-import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
@@ -27,18 +26,28 @@ private static Map twoTags() {
return tags;
}
+ /** The candidate map shape currentMacAddresses returns; this class only reads its keys, so
+ * the indices are arbitrary placeholders. */
+ private static Map macs(final String... addresses) {
+ final Map byMac = new HashMap<>();
+ for (int i = 0; i < addresses.length; i++) {
+ byMac.put(addresses[i], i);
+ }
+ return byMac;
+ }
+
/** Answers a different address set per accessory, so a mix-up between tags would show. */
- private static AccessoryMacResolver resolverFor(final Map> byJson) {
- return json -> byJson.getOrDefault(json, List.of());
+ private static AccessoryMacResolver resolverFor(final Map> byJson) {
+ return json -> byJson.getOrDefault(json, Map.of());
}
@Test
public void mapsEveryCandidateAddressBackToItsTag() {
- final Map> answers = new HashMap<>();
+ final Map> answers = new HashMap<>();
answers.put("{\"type\":\"accessory\",\"tag\":\"keys\"}",
- List.of("AA:AA:AA:AA:AA:01", "AA:AA:AA:AA:AA:02"));
+ macs("AA:AA:AA:AA:AA:01", "AA:AA:AA:AA:AA:02"));
answers.put("{\"type\":\"accessory\",\"tag\":\"bike\"}",
- List.of("BB:BB:BB:BB:BB:01"));
+ macs("BB:BB:BB:BB:BB:01"));
final NearbyTagIndex index = new NearbyTagIndex();
index.rebuild(twoTags(), resolverFor(answers), 0L);
@@ -63,7 +72,7 @@ public void anAddressThatIsNotOursResolvesToNothing() {
@Test
public void matchingIgnoresCase() {
final NearbyTagIndex index = new NearbyTagIndex();
- index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of("j", List.of("aa:bb:cc:dd:ee:ff"))), 0L);
+ index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of("j", macs("aa:bb:cc:dd:ee:ff"))), 0L);
assertEquals(KEYS, index.beaconIdFor("AA:BB:CC:DD:EE:FF"));
assertEquals(KEYS, index.beaconIdFor("aa:bb:cc:dd:ee:ff"));
@@ -98,8 +107,8 @@ public void expiryIsShorterThanTheRolloverInterval() {
@Test
public void rebuildingReplacesTheOldAddressesRatherThanAccumulating() {
final NearbyTagIndex index = new NearbyTagIndex();
- index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of("j", List.of("AA:AA:AA:AA:AA:01"))), 0L);
- index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of("j", List.of("AA:AA:AA:AA:AA:99"))), 1L);
+ index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of("j", macs("AA:AA:AA:AA:AA:01"))), 0L);
+ index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of("j", macs("AA:AA:AA:AA:AA:99"))), 1L);
assertEquals(1, index.size());
assertNull("a rolled-past address must stop matching", index.beaconIdFor("AA:AA:AA:AA:AA:01"));
@@ -117,7 +126,7 @@ public void oneUnresolvableTagDoesNotCostTheOthers() {
tags.put(BIKE, "unbackfilled");
final NearbyTagIndex index = new NearbyTagIndex();
- index.rebuild(tags, resolverFor(Map.of("good", List.of("AA:AA:AA:AA:AA:01"))), 0L);
+ index.rebuild(tags, resolverFor(Map.of("good", macs("AA:AA:AA:AA:AA:01"))), 0L);
assertEquals(KEYS, index.beaconIdFor("AA:AA:AA:AA:AA:01"));
assertEquals(1, index.size());
@@ -128,7 +137,7 @@ public void resolvesEachTagExactlyOncePerRebuild() {
final AtomicInteger calls = new AtomicInteger();
final AccessoryMacResolver counting = json -> {
calls.incrementAndGet();
- return List.of();
+ return Map.of();
};
new NearbyTagIndex().rebuild(twoTags(), counting, 0L);
From e81cc2f45807c0d9ff2b1dc052a08c2eb6e5cfec Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Mon, 24 Aug 2026 13:45:04 +0200
Subject: [PATCH 29/61] Keep what a tag last said about itself over Bluetooth
The battery field on an accessory record is written by Apple's own
devices as they pass the tag, so for anybody without one it is either
frozen at whatever an export captured or never written at all - both of
the third-party tags this was tested against still report 0, "not yet
reported". For those users the advertisement is the only battery source
there is, and it is audible only while the tag is in range, so the one
reading they can get vanished thirty seconds after it arrived.
A new table keeps the last sighting per tag: when it was heard, the
battery level it reported, and the status byte that level was decoded
out of. One row per tag, overwritten, because every advertisement
carries the same two bits - a log of them would be thousands of rows
saying "full" to answer a question that only ever needs the latest one.
Named for the sighting rather than for the battery, although the battery
is all it carries today. A position measured alongside a sighting is the
obvious next column, and adding one is then an additive migration rather
than a table rename. The RSSI is deliberately not among them: a battery
level from an hour ago is still roughly the battery level, while a
signal strength from an hour ago describes a distance that no longer
exists, and storing it would only invite showing it.
Its own table rather than a column on an existing one. UserBeaconOptions
holds what the owner decided about a tag and an account refresh is
careful never to touch it; OwnedBeacons is the cache of what Apple said,
and a reading this phone took is not Apple's to overwrite.
An unrecognised stored level reads back as no reading rather than as the
nearest one this build knows, so a row written by a later version cannot
be presented as something it does not say. The raw byte stays in the row
either way, which is what a disputed reading gets re-derived from.
---
.../7.json | 498 ++++++++++++++++++
.../db/repo/KeepingWhatATagLastSaidTest.java | 194 +++++++
.../OpenTagViewerDatabaseMigrationTest.java | 99 +++-
.../db/repo/BeaconRepository.java | 65 +++
.../db/repo/model/LastSightingData.java | 29 +
.../db/room/OpenTagViewerDatabase.java | 39 +-
.../db/room/dao/LastBleSightingDao.java | 33 ++
.../db/room/entity/LastBleSighting.java | 101 ++++
8 files changed, 1048 insertions(+), 10 deletions(-)
create mode 100644 app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/7.json
create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/KeepingWhatATagLastSaidTest.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/LastSightingData.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/LastBleSightingDao.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LastBleSighting.java
diff --git a/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/7.json b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/7.json
new file mode 100644
index 00000000..d1814392
--- /dev/null
+++ b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/7.json
@@ -0,0 +1,498 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 7,
+ "identityHash": "8aceaf4e5a85415cea681b4344a1d402",
+ "entities": [
+ {
+ "tableName": "Import",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `version` TEXT, `imported_at` INTEGER NOT NULL, `exported_at` INTEGER NOT NULL, `source_user` TEXT, `via` TEXT)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "importedAt",
+ "columnName": "imported_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "exportedAt",
+ "columnName": "exported_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceUser",
+ "columnName": "source_user",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "exportedVia",
+ "columnName": "via",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "BeaconNamingRecord",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `version` TEXT, `content` TEXT, `is_removed` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "importId",
+ "columnName": "import_id",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isRemoved",
+ "columnName": "is_removed",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_BeaconNamingRecord_import_id",
+ "unique": false,
+ "columnNames": [
+ "import_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_BeaconNamingRecord_import_id` ON `${TABLE_NAME}` (`import_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "Import",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "import_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "OwnedBeacons",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `content` TEXT, `version` TEXT, `is_removed` INTEGER NOT NULL, `from_account` INTEGER NOT NULL, `fruitless_scans` INTEGER NOT NULL DEFAULT 0, `last_scan_at` INTEGER, `ignored_at` INTEGER, `accessory_json` TEXT, `alignment_plist` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "importId",
+ "columnName": "import_id",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isRemoved",
+ "columnName": "is_removed",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromAccount",
+ "columnName": "from_account",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fruitlessScans",
+ "columnName": "fruitless_scans",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "lastScanAt",
+ "columnName": "last_scan_at",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "ignoredAt",
+ "columnName": "ignored_at",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "accessoryJson",
+ "columnName": "accessory_json",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "alignmentPlist",
+ "columnName": "alignment_plist",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_OwnedBeacons_import_id",
+ "unique": false,
+ "columnNames": [
+ "import_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_OwnedBeacons_import_id` ON `${TABLE_NAME}` (`import_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "Import",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "import_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "LocationReport",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hash_id` TEXT NOT NULL, `beacon_id` TEXT NOT NULL, `published_at` INTEGER NOT NULL, `description` TEXT, `timestamp` INTEGER NOT NULL, `confidence` INTEGER NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `horizontal_accuracy` INTEGER NOT NULL, `status` INTEGER NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`hash_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "hashId",
+ "columnName": "hash_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "beaconId",
+ "columnName": "beacon_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publishedAt",
+ "columnName": "published_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "confidence",
+ "columnName": "confidence",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "horizontalAccuracy",
+ "columnName": "horizontal_accuracy",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdate",
+ "columnName": "last_update",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "hash_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_LocationReport_hash_id_beacon_id_timestamp",
+ "unique": false,
+ "columnNames": [
+ "hash_id",
+ "beacon_id",
+ "timestamp"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_LocationReport_hash_id_beacon_id_timestamp` ON `${TABLE_NAME}` (`hash_id`, `beacon_id`, `timestamp`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "OwnedBeacons",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "beacon_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "DailyHistoryFetchRecord",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_start_time` INTEGER NOT NULL, `beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`day_start_time`, `beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "dayStartTime",
+ "columnName": "day_start_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "beaconId",
+ "columnName": "beacon_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdate",
+ "columnName": "last_update",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "day_start_time",
+ "beacon_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_DailyHistoryFetchRecord_beacon_id",
+ "unique": false,
+ "columnNames": [
+ "beacon_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_DailyHistoryFetchRecord_beacon_id` ON `${TABLE_NAME}` (`beacon_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "OwnedBeacons",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "beacon_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "UserBeaconOptions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, `ui_name` TEXT, `ui_emoji` TEXT, `ui_order` INTEGER, PRIMARY KEY(`beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "beaconId",
+ "columnName": "beacon_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdate",
+ "columnName": "last_update",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uiName",
+ "columnName": "ui_name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "uiEmoji",
+ "columnName": "ui_emoji",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "uiOrder",
+ "columnName": "ui_order",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "beacon_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "OwnedBeacons",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "beacon_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "LastBleSighting",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beacon_id` TEXT NOT NULL, `heard_at` INTEGER NOT NULL, `battery_level` TEXT NOT NULL, `status_byte` INTEGER NOT NULL, PRIMARY KEY(`beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "beaconId",
+ "columnName": "beacon_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "heardAt",
+ "columnName": "heard_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "batteryLevel",
+ "columnName": "battery_level",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "statusByte",
+ "columnName": "status_byte",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "beacon_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "OwnedBeacons",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "beacon_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8aceaf4e5a85415cea681b4344a1d402')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/KeepingWhatATagLastSaidTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/KeepingWhatATagLastSaidTest.java
new file mode 100644
index 00000000..c5db766a
--- /dev/null
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/KeepingWhatATagLastSaidTest.java
@@ -0,0 +1,194 @@
+package dev.wander.android.opentagviewer.db.repo;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import androidx.room.Room;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Optional;
+
+import dev.wander.android.opentagviewer.ble.FindMyAdvertisement.BatteryLevel;
+import dev.wander.android.opentagviewer.db.repo.model.LastSightingData;
+import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase;
+import dev.wander.android.opentagviewer.db.room.entity.LastBleSighting;
+import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon;
+
+/**
+ * What a tag said over the air, kept after the tag itself has gone quiet.
+ *
+ * Why it is stored at all. The accessory record's own battery field is written by
+ * Apple's devices as they walk past the tag, so for somebody with no Apple device it reads 0,
+ * "not yet reported", forever - which is what both of the real tags this was built against still
+ * report. The advertisement is then the only source there is, and it is only audible while the
+ * tag is in range. Not keeping it would mean the one battery reading these users can get
+ * disappears thirty seconds after it arrives.
+ *
+ *
The most recent sighting only. Every advertisement carries the same two bits, so a
+ * history of them would be thousands of rows saying "full" to answer a question that only ever
+ * needs the last one.
+ */
+@RunWith(AndroidJUnit4.class)
+public class KeepingWhatATagLastSaidTest {
+
+ private static final String A_TAG = "a-tag";
+ private static final String ANOTHER_TAG = "another-tag";
+ private static final String A_PLIST = "";
+
+ /** Status bytes whose top two bits read as each level, as a real one would. */
+ private static final int FULL_BYTE = 0b0000_0000;
+ private static final int MEDIUM_BYTE = 0b0100_0000;
+ private static final int LOW_BYTE = 0b1000_0000;
+ private static final int VERY_LOW_BYTE = 0b1100_0000;
+
+ private static final long MORNING = 1_700_000_000_000L;
+ private static final long AFTERNOON = MORNING + 21_600_000L;
+
+ private OpenTagViewerDatabase db;
+ private BeaconRepository repo;
+
+ @Before
+ public void openAnInMemoryDatabase() {
+ this.db = Room.inMemoryDatabaseBuilder(
+ getInstrumentation().getTargetContext(), OpenTagViewerDatabase.class)
+ .allowMainThreadQueries()
+ .build();
+
+ this.repo = new BeaconRepository(this.db, (plist, alignment) -> "{\"type\":\"accessory\"}");
+
+ this.insertTag(A_TAG);
+ this.insertTag(ANOTHER_TAG);
+ }
+
+ @After
+ public void closeIt() {
+ this.db.close();
+ }
+
+ private void insertTag(final String id) {
+ this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder()
+ .id(id).content(A_PLIST).accessoryJson("{\"type\":\"accessory\"}")
+ .version("0.0.2").fromAccount(false).isRemoved(false).build());
+ }
+
+ private Optional readBack(final String beaconId) {
+ return this.repo.getLastSighting(beaconId).blockingFirst();
+ }
+
+ /** A tag nothing has ever heard has no sighting, and must not be given one. */
+ @Test
+ public void aTagNeverHeardHasNothingStored() {
+ assertTrue("a tag that has never been heard must not report a battery level",
+ this.readBack(A_TAG).isEmpty());
+ }
+
+ @Test
+ public void whatTheTagSaidSurvivesTheSightingThatCarriedIt() {
+ this.repo.storeLastSighting(A_TAG, BatteryLevel.MEDIUM, MEDIUM_BYTE, MORNING)
+ .blockingAwait();
+
+ final Optional stored = this.readBack(A_TAG);
+
+ assertTrue(stored.isPresent());
+ assertEquals(BatteryLevel.MEDIUM, stored.get().getBatteryLevel());
+ assertEquals("a sighting must carry the moment it was heard, or nothing on it can be"
+ + " shown with its age", MORNING, stored.get().getHeardAtMs());
+ assertEquals("the raw byte is what a disputed reading gets re-derived from",
+ MEDIUM_BYTE, stored.get().getStatusByte());
+ }
+
+ /**
+ * A later sighting replaces the earlier one rather than joining it. The point of the row is
+ * "what it last said", and a tag draining from full to low must not still be able to answer
+ * "full".
+ */
+ @Test
+ public void aFresherSightingReplacesTheOneBeforeIt() {
+ this.repo.storeLastSighting(A_TAG, BatteryLevel.FULL, FULL_BYTE, MORNING).blockingAwait();
+ this.repo.storeLastSighting(A_TAG, BatteryLevel.LOW, LOW_BYTE, AFTERNOON).blockingAwait();
+
+ final Optional stored = this.readBack(A_TAG);
+
+ assertTrue(stored.isPresent());
+ assertEquals(BatteryLevel.LOW, stored.get().getBatteryLevel());
+ assertEquals(AFTERNOON, stored.get().getHeardAtMs());
+
+ try (var cursor = this.db.query(
+ "SELECT COUNT(*) FROM LastBleSighting WHERE beacon_id = ?",
+ new Object[]{A_TAG})) {
+ assertTrue(cursor.moveToFirst());
+ assertEquals("the table holds the latest sighting per tag, not a history of them",
+ 1, cursor.getInt(0));
+ }
+ }
+
+ /** One tag's sighting is not another's, which a single-row-per-tag table has to get right. */
+ @Test
+ public void eachTagKeepsItsOwn() {
+ this.repo.storeLastSighting(A_TAG, BatteryLevel.FULL, FULL_BYTE, MORNING).blockingAwait();
+ this.repo.storeLastSighting(ANOTHER_TAG, BatteryLevel.VERY_LOW, VERY_LOW_BYTE, MORNING)
+ .blockingAwait();
+
+ assertEquals(BatteryLevel.FULL, this.readBack(A_TAG).get().getBatteryLevel());
+ assertEquals(BatteryLevel.VERY_LOW, this.readBack(ANOTHER_TAG).get().getBatteryLevel());
+ }
+
+ /**
+ * A battery level this build does not know makes the whole sighting unreadable rather than a
+ * guess.
+ *
+ * The case is a row written by a later version that understands a state this one does not,
+ * met after a downgrade or a shared database. Every available way to map it onto the four
+ * states here produces a wrong reading shown as a right one, so the row is passed over. It
+ * stays in the table, raw byte and all, for whoever is debugging it.
+ */
+ @Test
+ public void anUnknownStoredLevelIsNoReadingRatherThanAGuess() {
+ this.db.lastBleSightingDao().insert(LastBleSighting.builder()
+ .beaconId(A_TAG)
+ .heardAt(MORNING)
+ .batteryLevel("HALF_ISH")
+ .statusByte(MEDIUM_BYTE)
+ .build());
+
+ assertTrue("an unrecognised level must not be rounded to a neighbouring one",
+ this.readBack(A_TAG).isEmpty());
+
+ try (var cursor = this.db.query(
+ "SELECT battery_level FROM LastBleSighting WHERE beacon_id = ?",
+ new Object[]{A_TAG})) {
+ assertTrue(cursor.moveToFirst());
+ assertEquals("the unreadable row must be left alone, not deleted",
+ "HALF_ISH", cursor.getString(0));
+ }
+ }
+
+ /**
+ * Removing a tag takes its stored sighting with it.
+ *
+ *
The foreign key is what makes that automatic. Without it a sighting would outlive the tag
+ * it describes and be waiting to be shown against a re-imported tag of the same id, dated
+ * before that tag was ever added here.
+ */
+ @Test
+ public void deletingATagTakesItsSightingWithIt() {
+ this.repo.storeLastSighting(A_TAG, BatteryLevel.FULL, FULL_BYTE, MORNING).blockingAwait();
+ this.repo.storeLastSighting(ANOTHER_TAG, BatteryLevel.LOW, LOW_BYTE, MORNING)
+ .blockingAwait();
+
+ this.db.getOpenHelper().getWritableDatabase()
+ .execSQL("DELETE FROM OwnedBeacons WHERE id = ?", new Object[]{A_TAG});
+
+ assertTrue("a deleted tag must not leave a sighting behind",
+ this.readBack(A_TAG).isEmpty());
+ assertFalse("deleting one tag must not touch another's sighting",
+ this.readBack(ANOTHER_TAG).isEmpty());
+ }
+}
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java
index b42b697f..025b0408 100644
--- a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java
@@ -457,12 +457,12 @@ public void migrate5To6_handlesEmptyDatabase() throws IOException {
* The path an actual user takes, which is never one version at a time.
*
*
People skip releases, so the upgrade that has to work is v1 straight to the current
- * version - five migrations in a row over rows written by a schema none of them were tested
+ * version - six migrations in a row over rows written by a schema none of them were tested
* against individually. Everything the user owns has to still be there at the end: their
* beacons, their location history, and the nicknames they set.
*/
@Test
- public void migrate1To6_directUpgradePreservesEverything() throws IOException {
+ public void migrate1To7_directUpgradePreservesEverything() throws IOException {
try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) {
insertImport(db, 1L);
insertOwnedBeaconV1(db, "beacon-a", 1L, BEACON_PLIST, false);
@@ -472,33 +472,118 @@ public void migrate1To6_directUpgradePreservesEverything() throws IOException {
}
SupportSQLiteDatabase db = helper.runMigrationsAndValidate(
- TEST_DB, 6, true,
+ TEST_DB, 7, true,
OpenTagViewerDatabase.MIGRATION_1_2,
OpenTagViewerDatabase.MIGRATION_2_3,
OpenTagViewerDatabase.MIGRATION_3_4,
OpenTagViewerDatabase.MIGRATION_4_5,
- OpenTagViewerDatabase.MIGRATION_5_6);
+ OpenTagViewerDatabase.MIGRATION_5_6,
+ OpenTagViewerDatabase.MIGRATION_6_7);
try (Cursor cursor = db.query("SELECT COUNT(*) FROM OwnedBeacons")) {
assertTrue(cursor.moveToFirst());
- assertEquals("beacons lost on a direct v1 to v6 upgrade", 2, cursor.getInt(0));
+ assertEquals("beacons lost on a direct v1 to v7 upgrade", 2, cursor.getInt(0));
}
try (Cursor cursor = db.query("SELECT COUNT(*) FROM LocationReport")) {
assertTrue(cursor.moveToFirst());
- assertEquals("location history lost on a direct v1 to v6 upgrade", 1, cursor.getInt(0));
+ assertEquals("location history lost on a direct v1 to v7 upgrade", 1, cursor.getInt(0));
}
try (Cursor cursor = db.query(
"SELECT ui_name, ui_order FROM UserBeaconOptions WHERE beacon_id = ?",
new Object[] {"beacon-a"})) {
- assertTrue("the user's nickname did not survive five migrations", cursor.moveToFirst());
+ assertTrue("the user's nickname did not survive six migrations", cursor.moveToFirst());
assertEquals("Wallet", cursor.getString(0));
assertTrue("nothing may arrive already arranged", cursor.isNull(1));
}
}
+ /**
+ * v6 to v7 adds an empty table and touches nothing else.
+ *
+ *
What it holds is heard by this phone's own radio, so there is nothing to backfill: a tag
+ * has no sighting until the next time it is actually heard. In particular the accessory
+ * record's own battery field is not copied across - that value is Apple's, is stale or unset
+ * for exactly the people this table exists for, and would land here dressed up as something
+ * this phone had heard.
+ */
+ @Test
+ public void migrate6To7_addsAnEmptyTableAndBackfillsNothing() throws IOException {
+ try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 5)) {
+ insertImport(db, 1L);
+ insertOwnedBeaconV5(db, BEACON_ID, 1L, BEACON_PLIST, false);
+ insertUserBeaconOptions(db, BEACON_ID, "Keys", null);
+ }
+
+ helper.runMigrationsAndValidate(TEST_DB, 6, true, OpenTagViewerDatabase.MIGRATION_5_6);
+ SupportSQLiteDatabase db = helper.runMigrationsAndValidate(
+ TEST_DB, 7, true, OpenTagViewerDatabase.MIGRATION_6_7);
+
+ try (Cursor cursor = db.query("SELECT COUNT(*) FROM LastBleSighting")) {
+ assertTrue("the new table is missing after the upgrade", cursor.moveToFirst());
+ assertEquals("an upgrade must not invent a sighting nobody heard", 0, cursor.getInt(0));
+ }
+
+ try (Cursor cursor = db.query(
+ "SELECT content FROM OwnedBeacons WHERE id = ?", new Object[] {BEACON_ID})) {
+ assertTrue("the beacon did not survive v6 to v7", cursor.moveToFirst());
+ assertEquals(BEACON_PLIST, cursor.getString(0));
+ }
+
+ try (Cursor cursor = db.query(
+ "SELECT ui_name FROM UserBeaconOptions WHERE beacon_id = ?",
+ new Object[] {BEACON_ID})) {
+ assertTrue("the user's nickname did not survive v6 to v7", cursor.moveToFirst());
+ assertEquals("Keys", cursor.getString(0));
+ }
+ }
+
+ /**
+ * A sighting written straight after the upgrade reads back, so the table the migration built
+ * is really the one the app expects - {@code runMigrationsAndValidate} compares the schema,
+ * and this checks it actually works.
+ */
+ @Test
+ public void migrate6To7_theNewTableAcceptsASighting() throws IOException {
+ try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 5)) {
+ insertImport(db, 1L);
+ insertOwnedBeaconV5(db, BEACON_ID, 1L, BEACON_PLIST, false);
+ }
+
+ helper.runMigrationsAndValidate(TEST_DB, 6, true, OpenTagViewerDatabase.MIGRATION_5_6);
+ SupportSQLiteDatabase db = helper.runMigrationsAndValidate(
+ TEST_DB, 7, true, OpenTagViewerDatabase.MIGRATION_6_7);
+
+ db.execSQL("INSERT INTO LastBleSighting"
+ + " (beacon_id, heard_at, battery_level, status_byte) VALUES (?, ?, ?, ?)",
+ new Object[] {BEACON_ID, 1700000000000L, "MEDIUM", 0b0100_0000});
+
+ try (Cursor cursor = db.query(
+ "SELECT heard_at, battery_level, status_byte FROM LastBleSighting"
+ + " WHERE beacon_id = ?", new Object[] {BEACON_ID})) {
+ assertTrue(cursor.moveToFirst());
+ assertEquals(1700000000000L, cursor.getLong(0));
+ assertEquals("MEDIUM", cursor.getString(1));
+ assertEquals(0b0100_0000, cursor.getInt(2));
+ }
+ }
+
+ @Test
+ public void migrate6To7_handlesEmptyDatabase() throws IOException {
+ helper.createDatabase(TEST_DB, 5).close();
+
+ helper.runMigrationsAndValidate(TEST_DB, 6, true, OpenTagViewerDatabase.MIGRATION_5_6);
+ SupportSQLiteDatabase db = helper.runMigrationsAndValidate(
+ TEST_DB, 7, true, OpenTagViewerDatabase.MIGRATION_6_7);
+
+ try (Cursor cursor = db.query("SELECT COUNT(*) FROM LastBleSighting")) {
+ assertTrue(cursor.moveToFirst());
+ assertEquals(0, cursor.getInt(0));
+ }
+ }
+
/**
* A beacon as v4 and v5 store one.
*
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
index c0332c58..01a8a509 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
@@ -13,11 +13,14 @@
import java.util.Optional;
import java.util.stream.Collectors;
+import dev.wander.android.opentagviewer.ble.FindMyAdvertisement;
import dev.wander.android.opentagviewer.data.model.BeaconLocationReport;
import dev.wander.android.opentagviewer.db.repo.model.BeaconData;
import dev.wander.android.opentagviewer.db.repo.model.ImportData;
+import dev.wander.android.opentagviewer.db.repo.model.LastSightingData;
import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase;
import dev.wander.android.opentagviewer.db.room.entity.BeaconNamingRecord;
+import dev.wander.android.opentagviewer.db.room.entity.LastBleSighting;
import dev.wander.android.opentagviewer.db.room.entity.DailyHistoryFetchRecord;
import dev.wander.android.opentagviewer.db.room.entity.Import;
import dev.wander.android.opentagviewer.db.room.entity.LocationReport;
@@ -569,6 +572,68 @@ public Completable recordAccessorySighting(
}).subscribeOn(Schedulers.io());
}
+ /**
+ * Keep what a tag just told this phone directly, replacing whatever it last said.
+ *
+ *
Why it outlives the sighting that produced it. The reading is shown live while the
+ * tag is audible and then ages out, because a signal strength or a "nearby" badge stops being
+ * true the moment the tag is carried off. A battery level does not: a tag that read "low" an
+ * hour ago is still low, and for a user with no Apple device there is no other source that
+ * will ever say so - see {@link LastBleSighting}. So the live display expires and what it
+ * said is kept.
+ *
+ *
Takes the fields of a sighting rather than a sighting object, because the one it would
+ * take lives in the {@code ble} package and carries a live RSSI this deliberately does not
+ * store. A parameter list is the honest signature for "these are the parts worth keeping".
+ *
+ *
Failure is swallowed, like every other write on this path. Losing a reading costs
+ * a screen one row until the tag is next heard, and this runs behind a passive scan the user
+ * did not ask for. Nothing they did may fail because of it.
+ */
+ public Completable storeLastSighting(
+ final String beaconId,
+ final FindMyAdvertisement.BatteryLevel batteryLevel,
+ final int statusByte,
+ final long heardAtUnixMs) {
+ return Completable.fromRunnable(() -> {
+ db.lastBleSightingDao().insert(LastBleSighting.builder()
+ .beaconId(beaconId)
+ .heardAt(heardAtUnixMs)
+ .batteryLevel(batteryLevel.name())
+ .statusByte(statusByte)
+ .build());
+ }).subscribeOn(Schedulers.io());
+ }
+
+ /**
+ * The last thing heard from this tag over Bluetooth, or empty if it never has been.
+ *
+ *
Empty is also the answer for a battery level this version does not recognise. A
+ * row written by a later build that knows a fifth state would otherwise have to be mapped
+ * onto one of the four here, and every choice available is a wrong reading presented as a
+ * right one. Showing nothing is the only honest option, and the raw byte is still in the row
+ * for anyone debugging it.
+ */
+ public Observable> getLastSighting(final String beaconId) {
+ return Observable.fromCallable(() -> {
+ final LastBleSighting row = db.lastBleSightingDao().getById(beaconId);
+ if (row == null) {
+ return Optional.empty();
+ }
+
+ try {
+ return Optional.of(new LastSightingData(
+ row.heardAt,
+ FindMyAdvertisement.BatteryLevel.valueOf(row.batteryLevel),
+ row.statusByte));
+ } catch (final IllegalArgumentException e) {
+ Log.w(TAG, "Ignoring an unrecognised stored battery level '" + row.batteryLevel
+ + "' for beaconId=" + beaconId);
+ return Optional.empty();
+ }
+ }).subscribeOn(Schedulers.io());
+ }
+
/**
* Persist a {@link FetchResult} from {@code PythonAppleService}: location reports
* go to the cache (delegating to {@link #storeToLocationCache}), and the freshly
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/LastSightingData.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/LastSightingData.java
new file mode 100644
index 00000000..5332ccb4
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/LastSightingData.java
@@ -0,0 +1,29 @@
+package dev.wander.android.opentagviewer.db.repo.model;
+
+import dev.wander.android.opentagviewer.ble.FindMyAdvertisement;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * What a tag last told this phone directly, and when it said it.
+ *
+ * Always older than now, and possibly much older - a tag left in a coat pocket says nothing
+ * for as long as it is out of range, and this is the last thing it managed to say before that.
+ * Anything showing any of it must show the age with it, which is why the timestamp is not
+ * optional here.
+ *
+ *
The battery level is all a sighting carries today. See {@code LastBleSighting} for why this
+ * is named for the sighting rather than for that one field.
+ */
+@AllArgsConstructor
+@Getter
+public final class LastSightingData {
+
+ /** When the advertisement was heard. */
+ private final long heardAtMs;
+
+ private final FindMyAdvertisement.BatteryLevel batteryLevel;
+
+ /** The status byte the level came out of, kept for bug reports. See the entity. */
+ private final int statusByte;
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java
index cdf7f2a6..6fd60d43 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java
@@ -10,12 +10,14 @@
import androidx.sqlite.db.SupportSQLiteDatabase;
import dev.wander.android.opentagviewer.db.room.dao.BeaconNamingRecordDao;
+import dev.wander.android.opentagviewer.db.room.dao.LastBleSightingDao;
import dev.wander.android.opentagviewer.db.room.dao.DailyHistoryFetchRecordDao;
import dev.wander.android.opentagviewer.db.room.dao.ImportDao;
import dev.wander.android.opentagviewer.db.room.dao.LocationReportDao;
import dev.wander.android.opentagviewer.db.room.dao.OwnedBeaconDao;
import dev.wander.android.opentagviewer.db.room.dao.UserBeaconOptionsDao;
import dev.wander.android.opentagviewer.db.room.entity.BeaconNamingRecord;
+import dev.wander.android.opentagviewer.db.room.entity.LastBleSighting;
import dev.wander.android.opentagviewer.db.room.entity.DailyHistoryFetchRecord;
import dev.wander.android.opentagviewer.db.room.entity.Import;
import dev.wander.android.opentagviewer.db.room.entity.LocationReport;
@@ -29,9 +31,10 @@
OwnedBeacon.class,
LocationReport.class,
DailyHistoryFetchRecord.class,
- UserBeaconOptions.class
+ UserBeaconOptions.class,
+ LastBleSighting.class
},
- version = 6
+ version = 7
)
public abstract class OpenTagViewerDatabase extends RoomDatabase {
private static OpenTagViewerDatabase INSTANCE = null;
@@ -129,6 +132,35 @@ public void migrate(@NonNull SupportSQLiteDatabase db) {
}
};
+ /**
+ * v6 → v7: adds {@code LastBleSighting}, the last thing this phone heard each tag say over
+ * Bluetooth - today its battery level, and whatever else a sighting turns out to be worth
+ * keeping later.
+ *
+ *
A new table rather than a column, for the reasons on {@link LastBleSighting} - briefly,
+ * neither of the tables that already hold something per beacon is a place a measurement taken
+ * by this phone belongs.
+ *
+ *
Creating an empty table changes nothing for an existing install: every tag simply has no
+ * sighting until the next time its advertisement is actually heard, which is the honest
+ * state. Nothing is backfilled, and in particular the accessory record's own battery field is
+ * not copied in - that value is Apple's, is stale or unset for exactly the users this table
+ * is for, and would arrive here presented as something this phone had heard.
+ */
+ public static final Migration MIGRATION_6_7 = new Migration(6, 7) {
+ @Override
+ public void migrate(@NonNull SupportSQLiteDatabase db) {
+ db.execSQL("CREATE TABLE IF NOT EXISTS `LastBleSighting` ("
+ + "`beacon_id` TEXT NOT NULL, "
+ + "`heard_at` INTEGER NOT NULL, "
+ + "`battery_level` TEXT NOT NULL, "
+ + "`status_byte` INTEGER NOT NULL, "
+ + "PRIMARY KEY(`beacon_id`), "
+ + "FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`)"
+ + " ON UPDATE CASCADE ON DELETE CASCADE )");
+ }
+ };
+
/**
* The database file's name, which is also read directly - see
* {@code OpenAirTagApplication.isFirstRun()}, which uses the file's presence to tell a new
@@ -145,7 +177,7 @@ public static OpenTagViewerDatabase getInstance(Context context) {
OpenTagViewerDatabase.class,
DATABASE_NAME)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
- MIGRATION_5_6)
+ MIGRATION_5_6, MIGRATION_6_7)
.build();
}
@@ -158,4 +190,5 @@ public static OpenTagViewerDatabase getInstance(Context context) {
public abstract LocationReportDao locationReportDao();
public abstract DailyHistoryFetchRecordDao dailyHistoryFetchRecordDao();
public abstract UserBeaconOptionsDao userBeaconOptionsDao();
+ public abstract LastBleSightingDao lastBleSightingDao();
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/LastBleSightingDao.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/LastBleSightingDao.java
new file mode 100644
index 00000000..159f9253
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/LastBleSightingDao.java
@@ -0,0 +1,33 @@
+package dev.wander.android.opentagviewer.db.room.dao;
+
+import androidx.room.Dao;
+import androidx.room.Insert;
+import androidx.room.OnConflictStrategy;
+import androidx.room.Query;
+
+import dev.wander.android.opentagviewer.db.room.entity.LastBleSighting;
+
+@Dao
+public interface LastBleSightingDao {
+ @Query("SELECT * FROM LastBleSighting WHERE beacon_id = :beaconId")
+ LastBleSighting getById(String beaconId);
+
+ /**
+ * Store this sighting, replacing whatever the tag last said.
+ *
+ *
{@code REPLACE} is safe here in a way it is not on other tables. It deletes the
+ * conflicting row before inserting, and on {@code OwnedBeacons} or {@code UserBeaconOptions}
+ * that delete either cascades into location history or throws away a nickname - see the long
+ * note on {@code UserBeaconOptionsDao.storeArrangement}. Nothing references this table, and
+ * every column is written on every insert, so there is nothing for the delete to take with
+ * it. It also works on the SQLite that ships with API 24, which the {@code ON CONFLICT DO
+ * UPDATE} form does not.
+ *
+ *
Worth revisiting if a column is ever added that not every sighting can fill. A
+ * position, for instance, would be absent whenever the phone had no fix - and with
+ * {@code REPLACE} a sighting carrying no position would erase the last one that did. At that
+ * point this wants to become the insert-then-update pair that table uses.
+ */
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ void insert(LastBleSighting sighting);
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LastBleSighting.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LastBleSighting.java
new file mode 100644
index 00000000..5d21082d
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LastBleSighting.java
@@ -0,0 +1,101 @@
+package dev.wander.android.opentagviewer.db.room.entity;
+
+import androidx.annotation.NonNull;
+import androidx.room.ColumnInfo;
+import androidx.room.Entity;
+import androidx.room.ForeignKey;
+import androidx.room.PrimaryKey;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+/**
+ * The last thing this phone heard a tag say over Bluetooth, and when it heard it.
+ *
+ *
Named for the sighting, not for the battery, although the battery is all it holds
+ * today. The row is "what the tag last told us directly", and the battery level is one field
+ * of that. Anything else worth keeping from a sighting - the position the phone was at when it
+ * heard it is the obvious candidate, and the one already asked for in PR #139 - is another column
+ * here rather than another table, and a plain additive migration. Naming the table after its
+ * first column would have meant a rename, and renaming a table is the one migration SQLite makes
+ * genuinely awkward.
+ *
+ *
Why any of it is kept. The battery value on the accessory record comes from Apple's
+ * devices as they walk past the tag, so for anyone without one it is either years old or never
+ * written at all - see {@code BatteryLevelDescription}, and note that both of the real tags this
+ * was developed against still report 0, "not yet reported". For those users the advertisement is
+ * the only source there is. Keeping what it said means a tag heard this morning can still say
+ * what it said this morning, instead of the screen going blank the moment the tag is out of
+ * earshot.
+ *
+ *
Only what stays true is kept. A battery level heard an hour ago is still roughly the
+ * battery level; a signal strength heard an hour ago is about a distance that no longer exists,
+ * so the RSSI on the sighting is deliberately not stored. Persisting it would invite showing it,
+ * and {@code NearbyTagLabel} explains at length why even a live RSSI may not be presented as a
+ * distance.
+ *
+ *
One row per tag, overwritten, not a history. Every advertisement carries the same two
+ * bits, so a log of them would be thousands of rows saying "full" to answer a question that only
+ * ever needs the most recent one. If a genuine sighting history is ever built - as a local
+ * alternative to Apple's location reports - it is a different shape, many rows per tag, and it
+ * wants its own table; this one would stay as the cheap "what is the latest" lookup.
+ *
+ *
Its own table rather than a column elsewhere. {@code UserBeaconOptions} is what the
+ * owner has decided about a tag and an account refresh is careful never to touch it, which is
+ * the wrong company for a measurement. {@code OwnedBeacons} is the cache of what Apple said,
+ * rewritten from the account, and a reading taken by this phone is not Apple's to overwrite.
+ */
+@Builder
+@AllArgsConstructor
+@Entity(
+ tableName = "LastBleSighting",
+ foreignKeys = {
+ @ForeignKey(
+ entity = OwnedBeacon.class,
+ parentColumns = {"id"},
+ childColumns = {"beacon_id"},
+ onUpdate = ForeignKey.CASCADE,
+ onDelete = ForeignKey.CASCADE
+ )
+ }
+)
+public class LastBleSighting {
+ @PrimaryKey
+ @NonNull
+ @ColumnInfo(name = "beacon_id")
+ public String beaconId;
+
+ /** When the advertisement was heard, so whatever it carried can be shown with its age. */
+ @ColumnInfo(name = "heard_at")
+ public long heardAt;
+
+ /**
+ * The battery level it reported, as the name of a
+ * {@code FindMyAdvertisement.BatteryLevel}.
+ *
+ *
Not its ordinal. An ordinal is a position in a source file, so reordering the enum - a
+ * change that looks harmless and compiles - would silently reinterpret every row already
+ * written on every user's phone. A name is only ever wrong if somebody renames a constant,
+ * which is a rename the compiler cannot hide either.
+ *
+ *
Read back through {@code BeaconRepository}, which treats an unrecognised name as no
+ * reading rather than guessing: a row written by a later version that knows a level this one
+ * does not must not be shown as some neighbouring level.
+ */
+ @NonNull
+ @ColumnInfo(name = "battery_level")
+ public String batteryLevel;
+
+ /**
+ * The whole status byte {@link #batteryLevel} was read out of.
+ *
+ *
Redundant on purpose, and cheap. The battery is two bits of it, decoded per a table that
+ * nobody outside Apple has confirmed in full - {@code LocationReportFields} is explicit about
+ * which parts of that byte are documented and which are inferred. Keeping the byte means a
+ * disputed reading can be re-derived from what was actually received, and that a bug report
+ * can quote the source rather than only this app's reading of it. The same reason the debug
+ * panel always shows the raw number beside the label.
+ */
+ @ColumnInfo(name = "status_byte")
+ public int statusByte;
+}
From 29f23c81d8a89e6f22b5b1350fdd9e46d145fb1a Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Mon, 24 Aug 2026 13:45:57 +0200
Subject: [PATCH 30/61] Feed the passive scan's battery reading into the same
write
The watch already told a listener when one of the user's tags was heard,
so a passive sighting could correct key alignment the way the ring
button's explicit scan does. That listener was handed a beacon id, an
address and a timestamp, which is everything alignment needs and nothing
else the advertisement said.
It now receives the sighting itself. The battery level rides on the same
advertisement as the address, both writes belong to the same event, and
both want the same once-a-minute-per-tag throttle - a second listener
for the battery would have fired on its own schedule for no reason. The
sighting also carries the status byte now, kept rather than discarded
once the level has been decoded, since the level is two bits of it read
against a table nobody outside Apple has confirmed in full.
AccessorySightingPersister stays the single place a sighting is written
down, which is what it exists for: the policy used to live in four
hand-copied methods across two screens, and a change to it had to be
applied to all four in lockstep. Only the passive path carries a battery
reading; a sighting proven by a ring attempt knows an address and
nothing more, so it writes only what it knows.
---
.../AccessorySightingPersister.java | 27 +++++++--
.../opentagviewer/ble/NearbyTagSighting.java | 11 ++++
.../opentagviewer/ble/NearbyTagWatcher.java | 23 ++++---
.../ble/NearbyTagSightingsTest.java | 6 +-
.../ble/NearbyTagWatcherTest.java | 60 +++++++++++++++----
5 files changed, 101 insertions(+), 26 deletions(-)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/AccessorySightingPersister.java b/app/src/main/java/dev/wander/android/opentagviewer/AccessorySightingPersister.java
index bebb6ae4..a208e337 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/AccessorySightingPersister.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/AccessorySightingPersister.java
@@ -4,11 +4,12 @@
import dev.wander.android.opentagviewer.ble.BleSoundTriggerPhase;
import dev.wander.android.opentagviewer.ble.BleSoundTriggerUpdate;
+import dev.wander.android.opentagviewer.ble.NearbyTagSighting;
import dev.wander.android.opentagviewer.db.repo.BeaconRepository;
/**
- * The one place a Bluetooth sighting is fed back into alignment self-correction, for both
- * screens and both kinds of sighting.
+ * The one place a Bluetooth sighting is written down, for both screens and both kinds of
+ * sighting: the alignment it proves, and the battery level it reported.
*
*
One class because the policy used to live in four hand-copied methods - a
* {@code correctAlignmentFromSighting} and a {@code keepWhatTheSightingProved} in each of
@@ -37,9 +38,16 @@ final class AccessorySightingPersister {
/**
* A passive sighting from a {@code NearbyTagWatcher} - shaped to be used directly as its
* {@code SightingListener}.
+ *
+ *
Two writes from the one advertisement, and they answer different questions. The address
+ * says which key the tag is broadcasting, which corrects alignment; the status byte says what
+ * its battery was, which is worth keeping long after the tag has gone quiet, because for a
+ * user with no Apple device nothing else will ever report it - see
+ * {@code BeaconRepository#storeLastSighting}.
*/
- void onSighting(final String beaconId, final String mac, final long seenAtMs) {
- this.persist(beaconId, mac, seenAtMs);
+ void onSighting(final NearbyTagSighting sighting, final String mac) {
+ this.persist(sighting.getBeaconId(), mac, sighting.getSeenAtMs());
+ this.persistLastSighting(sighting);
}
/**
@@ -59,4 +67,15 @@ private void persist(final String beaconId, final String mac, final long seenAtM
.subscribe(() -> { }, error -> Log.w(TAG,
"Failed to persist a sighting for beaconId=" + beaconId, error));
}
+
+ private void persistLastSighting(final NearbyTagSighting sighting) {
+ this.beaconRepo.storeLastSighting(
+ sighting.getBeaconId(),
+ sighting.getBatteryLevel(),
+ sighting.getStatusByte(),
+ sighting.getSeenAtMs())
+ .subscribe(() -> { }, error -> Log.w(TAG,
+ "Failed to persist a sighting reading for beaconId="
+ + sighting.getBeaconId(), error));
+ }
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSighting.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSighting.java
index 1698c2cb..98129425 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSighting.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagSighting.java
@@ -30,6 +30,17 @@ public final class NearbyTagSighting {
private final FindMyAdvertisement.BatteryLevel batteryLevel;
+ /**
+ * The status byte {@link #batteryLevel} was decoded from.
+ *
+ *
Carried alongside the reading rather than discarded once it has been decoded, because
+ * the reading is two bits of it interpreted against a table only partly confirmed outside
+ * Apple - see {@link dev.wander.android.opentagviewer.util.parse.LocationReportFields}. It
+ * is what gets persisted with a stored reading, so a disputed one can be re-derived from
+ * what was actually received.
+ */
+ private final int statusByte;
+
/** Whether the beacon said it was separated from its owner. See {@link FindMyAdvertisement}. */
private final FindMyAdvertisement.State state;
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
index 967ddbc5..bb0f16df 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcher.java
@@ -67,9 +67,16 @@ interface Clock {
* stored alignment that has drifted since the last fetch. It stays inside
* {@code currentMacAddresses}' 12 hour margin for a while and then, once the drift exceeds
* that, simply stops being found - with nothing failing anywhere to say why.
+ *
+ *
Handed the whole sighting, not just the address it was heard at. Alignment only
+ * needs the address, but the same advertisement also carries the tag's battery level, and
+ * that is worth keeping past the moment it was heard - see
+ * {@code BeaconRepository#storeLastSighting}. Both writes belong to the same event and are
+ * throttled by the same rule, so there is one callback carrying everything the advertisement
+ * said rather than a second listener firing on its own schedule.
*/
public interface SightingListener {
- void onSighting(String beaconId, String mac, long seenAtMs);
+ void onSighting(NearbyTagSighting sighting, String mac);
}
/**
@@ -178,8 +185,7 @@ public void onScanResult(final int callbackType, final ScanResult result) {
if (!emitter.isDisposed()) {
emitter.onNext(sighting);
}
- maybeNotifySightingListener(
- sighting.getBeaconId(), result.getDevice().getAddress());
+ maybeNotifySightingListener(sighting, result.getDevice().getAddress());
}
@Override
@@ -311,7 +317,7 @@ NearbyTagSighting sightingFrom(final ScanResult result) {
}
return new NearbyTagSighting(beaconId, result.getRssi(), advertisement.getBatteryLevel(),
- advertisement.getState(), this.clock.nowMs());
+ advertisement.getStatusByte(), advertisement.getState(), this.clock.nowMs());
}
/**
@@ -320,18 +326,17 @@ NearbyTagSighting sightingFrom(final ScanResult result) {
*
Off-thread because the real listener persists to Room through a Python call - see the
* interface doc - and this runs from {@code onScanResult}, which must not block.
*/
- void maybeNotifySightingListener(final String beaconId, final String mac) {
+ void maybeNotifySightingListener(final NearbyTagSighting sighting, final String mac) {
if (this.sightingListener == null) {
return;
}
final long nowMs = this.clock.nowMs();
- final Long lastCallMs = this.lastListenerCallMs.get(beaconId);
+ final Long lastCallMs = this.lastListenerCallMs.get(sighting.getBeaconId());
if (lastCallMs != null && nowMs - lastCallMs < SIGHTING_LISTENER_INTERVAL_MS) {
return;
}
- this.lastListenerCallMs.put(beaconId, nowMs);
+ this.lastListenerCallMs.put(sighting.getBeaconId(), nowMs);
- Schedulers.io().scheduleDirect(
- () -> this.sightingListener.onSighting(beaconId, mac, nowMs));
+ Schedulers.io().scheduleDirect(() -> this.sightingListener.onSighting(sighting, mac));
}
}
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagSightingsTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagSightingsTest.java
index 5070f6a5..a4b4f5fb 100644
--- a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagSightingsTest.java
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagSightingsTest.java
@@ -16,7 +16,7 @@ public class NearbyTagSightingsTest {
private static final String BIKE = "bike-beacon-id";
private static NearbyTagSighting seen(final String beaconId, final long atMs) {
- return new NearbyTagSighting(beaconId, -50, BatteryLevel.FULL, State.SEPARATED, atMs);
+ return new NearbyTagSighting(beaconId, -50, BatteryLevel.FULL, 0x00, State.SEPARATED, atMs);
}
@Test
@@ -60,8 +60,8 @@ public void beingSeenAgainRenewsIt() {
@Test
public void theLatestSightingWins() {
final NearbyTagSightings sightings = new NearbyTagSightings();
- sightings.record(new NearbyTagSighting(KEYS, -90, BatteryLevel.FULL, State.SEPARATED, 0L));
- sightings.record(new NearbyTagSighting(KEYS, -40, BatteryLevel.LOW, State.SEPARATED, 100L));
+ sightings.record(new NearbyTagSighting(KEYS, -90, BatteryLevel.FULL, 0x00, State.SEPARATED, 0L));
+ sightings.record(new NearbyTagSighting(KEYS, -40, BatteryLevel.LOW, 0x80, State.SEPARATED, 100L));
final NearbyTagSighting fresh = sightings.freshFor(KEYS, 100L);
assertEquals(-40, fresh.getRssi());
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcherTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcherTest.java
index d4e1aea7..013ce64c 100644
--- a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcherTest.java
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagWatcherTest.java
@@ -33,9 +33,23 @@ private static AccessoryMacResolver anyResolver() {
return json -> Map.of();
}
+ /** A sighting as the scan callback would have built one, for a tag reporting a full battery. */
+ private static NearbyTagSighting sightingOf(final String beaconId) {
+ return sightingOf(beaconId, FindMyAdvertisement.BatteryLevel.FULL, 0b0000_0000);
+ }
+
+ private static NearbyTagSighting sightingOf(
+ final String beaconId,
+ final FindMyAdvertisement.BatteryLevel level,
+ final int statusByte) {
+ return new NearbyTagSighting(beaconId, -60, level, statusByte,
+ FindMyAdvertisement.State.SEPARATED, 1_700_000_000_000L);
+ }
+
/** Records each call and counts down a latch, so a test can wait for the async dispatch. */
private static final class RecordingListener implements NearbyTagWatcher.SightingListener {
final List calls = new CopyOnWriteArrayList<>();
+ final List sightings = new CopyOnWriteArrayList<>();
private final CountDownLatch latch;
RecordingListener(final int expectedCalls) {
@@ -43,8 +57,9 @@ private static final class RecordingListener implements NearbyTagWatcher.Sightin
}
@Override
- public void onSighting(final String beaconId, final String mac, final long seenAtMs) {
- this.calls.add(beaconId);
+ public void onSighting(final NearbyTagSighting sighting, final String mac) {
+ this.calls.add(sighting.getBeaconId());
+ this.sightings.add(sighting);
this.latch.countDown();
}
@@ -69,7 +84,7 @@ public void notifiesTheListenerOnAMatchedSighting() throws InterruptedException
final long[] clock = {0L};
final NearbyTagWatcher watcher = watcherWith(listener, clock);
- watcher.maybeNotifySightingListener(BEACON_ID, MAC);
+ watcher.maybeNotifySightingListener(sightingOf(BEACON_ID), MAC);
listener.awaitThenSettle();
assertEquals(1, listener.calls.size());
@@ -81,9 +96,9 @@ public void throttlesRepeatedCallsForTheSameBeacon() throws InterruptedException
final long[] clock = {0L};
final NearbyTagWatcher watcher = watcherWith(listener, clock);
- watcher.maybeNotifySightingListener(BEACON_ID, MAC);
+ watcher.maybeNotifySightingListener(sightingOf(BEACON_ID), MAC);
clock[0] = NearbyTagWatcher.SIGHTING_LISTENER_INTERVAL_MS - 1;
- watcher.maybeNotifySightingListener(BEACON_ID, MAC);
+ watcher.maybeNotifySightingListener(sightingOf(BEACON_ID), MAC);
listener.awaitThenSettle();
assertEquals("the second call landed inside the throttle window", 1, listener.calls.size());
@@ -95,9 +110,9 @@ public void callsAgainOnceTheThrottleWindowHasPassed() throws InterruptedExcepti
final long[] clock = {0L};
final NearbyTagWatcher watcher = watcherWith(listener, clock);
- watcher.maybeNotifySightingListener(BEACON_ID, MAC);
+ watcher.maybeNotifySightingListener(sightingOf(BEACON_ID), MAC);
clock[0] = NearbyTagWatcher.SIGHTING_LISTENER_INTERVAL_MS;
- watcher.maybeNotifySightingListener(BEACON_ID, MAC);
+ watcher.maybeNotifySightingListener(sightingOf(BEACON_ID), MAC);
listener.awaitThenSettle();
assertEquals(2, listener.calls.size());
@@ -109,7 +124,32 @@ public void aNullListenerIsSimplySkipped() {
anyResolver(), null, new NearbyTagIndex(), () -> 0L);
// Must not throw.
- watcher.maybeNotifySightingListener(BEACON_ID, MAC);
+ watcher.maybeNotifySightingListener(sightingOf(BEACON_ID), MAC);
+ }
+
+ /**
+ * The listener is handed the whole sighting, because the same advertisement feeds two
+ * different writes: the address corrects key alignment, and the battery level is kept for
+ * long after the tag has gone quiet. A listener given only an address could not do the
+ * second, and a second listener for it would fire on its own schedule rather than this
+ * one's throttle.
+ */
+ @Test
+ public void handsOverWhatTheAdvertisementSaidNotJustWhereItCameFrom()
+ throws InterruptedException {
+ final RecordingListener listener = new RecordingListener(1);
+ final long[] clock = {0L};
+ final NearbyTagWatcher watcher = watcherWith(listener, clock);
+
+ watcher.maybeNotifySightingListener(
+ sightingOf(BEACON_ID, FindMyAdvertisement.BatteryLevel.LOW, 0b1000_0000), MAC);
+
+ listener.awaitThenSettle();
+ assertEquals(1, listener.sightings.size());
+ assertEquals(FindMyAdvertisement.BatteryLevel.LOW,
+ listener.sightings.get(0).getBatteryLevel());
+ assertEquals("the raw status byte must survive the hand-over too",
+ 0b1000_0000, listener.sightings.get(0).getStatusByte());
}
@Test
@@ -118,8 +158,8 @@ public void eachBeaconIsThrottledIndependently() throws InterruptedException {
final long[] clock = {0L};
final NearbyTagWatcher watcher = watcherWith(listener, clock);
- watcher.maybeNotifySightingListener(BEACON_ID, MAC);
- watcher.maybeNotifySightingListener("bike-beacon-id", "11:22:33:44:55:66");
+ watcher.maybeNotifySightingListener(sightingOf(BEACON_ID), MAC);
+ watcher.maybeNotifySightingListener(sightingOf("bike-beacon-id"), "11:22:33:44:55:66");
listener.awaitThenSettle();
assertTrue("a busy tag must not starve another tag's correction",
From e591aa8257a003942f80d3186e1b1702e94b469b Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Mon, 24 Aug 2026 13:46:34 +0200
Subject: [PATCH 31/61] Give what the radio heard its own section on the device
screen
The battery reading taken off the tag sat among the accessory record's
own fields, under a heading whose first row is "Source: read from your
Apple account". A value this phone measured a minute ago therefore read
as one more thing iCloud had said. The row also carried the signal
strength appended to it, so a heading that said "Battery" was showing
two unrelated measurements at once.
There is now an "Over Bluetooth" section, after the record's fields and
before the debug panel, with a row each for when the tag was last heard,
how strongly, and what battery it reported. The section names the
channel rather than calling itself live, because it deliberately outlives
the tag going quiet.
Which is where the two readings part company, and the reason they are
separate rows rather than one line. Once the tag is out of earshot the
battery row stays and the "Last seen" row above it carries its age; the
signal row is withdrawn, because it describes how strongly something was
heard at an instant and there is no wording that makes an hour-old one
useful. The whole section is hidden for a tag this phone has never
heard, rather than showing empty rows that would read as "nothing
detected" - a claim nobody here can make.
"Last seen" is redrawn once a minute while the tag is quiet. Nothing
else on this screen redraws while it sits open, so without that the row
would freeze at whatever it said when the tag went silent and keep
saying it for as long as somebody watched, which is exactly the
staleness the row exists to report rather than commit.
DeviceInfoLiveBatteryLayoutTest follows the row it was written for and
becomes DeviceInfoBluetoothSectionLayoutTest. It now pins the divider
and the heading alongside the rows, since leaving either behind when the
rows go would put a titled, empty section on the screen.
---
.../DeviceInfoBluetoothSectionLayoutTest.java | 197 ++++++++++++++++++
.../ui/DeviceInfoLiveBatteryLayoutTest.java | 163 ---------------
.../opentagviewer/DeviceInfoActivity.java | 158 ++++++++++++--
.../main/res/layout/activity_device_info.xml | 137 ++++++++++--
app/src/main/res/values-de/strings.xml | 5 +-
app/src/main/res/values-en/strings.xml | 5 +-
app/src/main/res/values-fr/strings.xml | 5 +-
app/src/main/res/values-ja/strings.xml | 5 +-
app/src/main/res/values-ko/strings.xml | 5 +-
app/src/main/res/values-nl/strings.xml | 5 +-
app/src/main/res/values-ru/strings.xml | 5 +-
app/src/main/res/values-zh-rCN/strings.xml | 5 +-
app/src/main/res/values-zh-rTW/strings.xml | 5 +-
app/src/main/res/values/strings.xml | 5 +-
14 files changed, 493 insertions(+), 212 deletions(-)
create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/ui/DeviceInfoBluetoothSectionLayoutTest.java
delete mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/ui/DeviceInfoLiveBatteryLayoutTest.java
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/DeviceInfoBluetoothSectionLayoutTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/DeviceInfoBluetoothSectionLayoutTest.java
new file mode 100644
index 00000000..212e8342
--- /dev/null
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/DeviceInfoBluetoothSectionLayoutTest.java
@@ -0,0 +1,197 @@
+package dev.wander.android.opentagviewer.ui;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.appcompat.view.ContextThemeWrapper;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import dev.wander.android.opentagviewer.R;
+
+/**
+ * The "Over Bluetooth" section on the device screen: what this phone's own radio heard from the
+ * tag, as opposed to everything above it, which is the accessory record Apple keeps.
+ *
+ * Hidden is its resting state, and that is the part worth pinning. The section is only
+ * filled in once the tag has actually been heard, and it deliberately never falls back to the
+ * iCloud battery value - the whole reason it sits outside the debug panel is that it says where
+ * its numbers came from. A stray edit making it visible by default would put an empty section on
+ * every device screen, reading as "nothing detected", which is a claim nobody here can make.
+ *
+ *
Inflation only: no activity, no account, no Bluetooth. Run with
+ * {@code ./gradlew :app:testEmulatorDebugAndroidTest}.
+ */
+@RunWith(AndroidJUnit4.class)
+public class DeviceInfoBluetoothSectionLayoutTest {
+
+ private static final int SCREEN_WIDTH_PX = 1080;
+
+ /**
+ * Every piece the section is made of, since they are shown and hidden together.
+ *
+ *
The divider and the heading are in here on purpose: leaving either behind when the rows
+ * go would put a titled, empty section on the screen, which is the failure this whole class
+ * is about.
+ */
+ private static final int[] SECTION_VIEWS = {
+ R.id.device_ble_divider,
+ R.id.device_ble_header,
+ R.id.device_settings_ble_last_seen,
+ R.id.device_settings_ble_signal,
+ R.id.device_settings_ble_battery,
+ };
+
+ private Context context;
+
+ @Before
+ public void setUp() {
+ this.context = new ContextThemeWrapper(
+ getInstrumentation().getTargetContext(), R.style.Theme_OpenTagViewer);
+ }
+
+ private View inflateDeviceInfo() {
+ final View[] root = new View[1];
+ getInstrumentation().runOnMainSync(() ->
+ root[0] = LayoutInflater.from(this.context)
+ .inflate(R.layout.activity_device_info, null));
+ return root[0];
+ }
+
+ @Test
+ public void theScreenStillInflates() {
+ assertNotNull(this.inflateDeviceInfo());
+ }
+
+ /**
+ * Every id {@code DeviceInfoActivity.showBluetoothSection} looks up has to resolve, or that
+ * piece is simply never shown and nothing fails.
+ */
+ @Test
+ public void theWholeSectionExists() {
+ final View screen = this.inflateDeviceInfo();
+
+ for (final int id : SECTION_VIEWS) {
+ assertNotNull("missing view in the Over Bluetooth section", screen.findViewById(id));
+ }
+ }
+
+ @Test
+ public void theSectionIsHiddenUntilTheTagIsHeard() {
+ final View screen = this.inflateDeviceInfo();
+
+ for (final int id : SECTION_VIEWS) {
+ assertEquals("a tag this phone has never heard must show no section at all,"
+ + " not an empty one",
+ View.GONE, screen.findViewById(id).getVisibility());
+ }
+ }
+
+ /** Shown, each row has to occupy real space rather than measuring to nothing. */
+ @Test
+ public void theRowsHaveRealSizeOnceShown() {
+ final int[][] size = new int[SECTION_VIEWS.length][2];
+
+ getInstrumentation().runOnMainSync(() -> {
+ final View screen = LayoutInflater.from(this.context)
+ .inflate(R.layout.activity_device_info, null);
+
+ for (final int id : SECTION_VIEWS) {
+ screen.findViewById(id).setVisibility(View.VISIBLE);
+ }
+
+ screen.measure(
+ View.MeasureSpec.makeMeasureSpec(SCREEN_WIDTH_PX, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(2400, View.MeasureSpec.EXACTLY));
+ screen.layout(0, 0, SCREEN_WIDTH_PX, 2400);
+
+ for (int i = 0; i < SECTION_VIEWS.length; i++) {
+ final View view = screen.findViewById(SECTION_VIEWS[i]);
+ size[i][0] = view.getMeasuredWidth();
+ size[i][1] = view.getMeasuredHeight();
+ }
+ });
+
+ for (int i = 0; i < SECTION_VIEWS.length; i++) {
+ assertTrue("view " + i + " measured " + size[i][0] + "x" + size[i][1],
+ size[i][0] > 0 && size[i][1] > 0);
+ }
+ }
+
+ /**
+ * It sits outside the debug panel, which is the whole point. Inside it, these readings
+ * would be invisible to everyone who has not turned debug data on, and the section exists
+ * because for somebody with no Apple device this is the only battery figure there is.
+ */
+ @Test
+ public void theSectionIsNotInsideTheDebugPanel() {
+ final View screen = this.inflateDeviceInfo();
+ final ViewGroup debugPanel = screen.findViewById(R.id.device_debug_info);
+
+ assertNotNull(debugPanel);
+ for (final int id : SECTION_VIEWS) {
+ assertTrue("what the radio heard must not be gated behind the debug switch",
+ debugPanel.findViewById(id) == null);
+ }
+ }
+
+ /** Half of what breaks only breaks in one mode. */
+ @Test
+ public void theSectionSurvivesDarkMode() {
+ final Configuration night = new Configuration(
+ this.context.getResources().getConfiguration());
+ night.uiMode = Configuration.UI_MODE_NIGHT_YES | Configuration.UI_MODE_TYPE_NORMAL;
+
+ final Context darkContext = new ContextThemeWrapper(
+ this.context.createConfigurationContext(night), R.style.Theme_OpenTagViewer);
+
+ final View[] screen = new View[1];
+ getInstrumentation().runOnMainSync(() -> screen[0] = LayoutInflater.from(darkContext)
+ .inflate(R.layout.activity_device_info, null));
+
+ for (final int id : SECTION_VIEWS) {
+ final View view = screen[0].findViewById(id);
+ assertNotNull(view);
+ assertEquals(View.GONE, view.getVisibility());
+ }
+ }
+
+ /**
+ * The short battery words are what this section and the tag card show. The debug panel's own
+ * strings spell out percentage ranges and a caveat, which is right there and far too long for
+ * a one-line row - so this pins that they stayed short.
+ */
+ @Test
+ public void theShortBatteryWordsStayShortEnoughForARow() {
+ for (final int id : new int[] {
+ R.string.battery_short_full,
+ R.string.battery_short_medium,
+ R.string.battery_short_low,
+ R.string.battery_short_very_low,
+ }) {
+ final String word = this.context.getString(id);
+ assertTrue("\"" + word + "\" is too long for a tag card line", word.length() <= 20);
+ }
+ }
+
+ /** The card line reads e.g. "Nearby · Battery full", so the format has to take the word. */
+ @Test
+ public void theNearbyLineFormatsWithABatteryWord() {
+ final String line = this.context.getString(R.string.nearby_now_with_battery,
+ this.context.getString(R.string.battery_short_low));
+
+ assertTrue("the battery word should appear in the line: " + line,
+ line.contains(this.context.getString(R.string.battery_short_low)));
+ }
+}
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/DeviceInfoLiveBatteryLayoutTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/DeviceInfoLiveBatteryLayoutTest.java
deleted file mode 100644
index aa539563..00000000
--- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/DeviceInfoLiveBatteryLayoutTest.java
+++ /dev/null
@@ -1,163 +0,0 @@
-package dev.wander.android.opentagviewer.ui;
-
-import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertTrue;
-
-import android.content.Context;
-import android.content.res.Configuration;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.TextView;
-
-import androidx.appcompat.view.ContextThemeWrapper;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
-
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-import dev.wander.android.opentagviewer.R;
-
-/**
- * The live battery row on the device screen: the one fed by hearing the tag itself over
- * Bluetooth, rather than by the iCloud record.
- *
- *
Hidden is its resting state, and that is the part worth pinning. It is only filled
- * in once the tag has actually been heard, and it deliberately never falls back to the iCloud
- * value - the whole reason it sits outside the debug panel is that it cannot be stale. A stray
- * edit making it visible by default would put an empty row on every device screen; one wiring it
- * to {@code batteryLevel} would silently reintroduce the staleness it exists to avoid.
- *
- *
Inflation only: no activity, no account, no Bluetooth. Run with
- * {@code ./gradlew :app:testEmulatorDebugAndroidTest}.
- */
-@RunWith(AndroidJUnit4.class)
-public class DeviceInfoLiveBatteryLayoutTest {
-
- private static final int SCREEN_WIDTH_PX = 1080;
-
- private Context context;
-
- @Before
- public void setUp() {
- this.context = new ContextThemeWrapper(
- getInstrumentation().getTargetContext(), R.style.Theme_OpenTagViewer);
- }
-
- private View inflateDeviceInfo() {
- final View[] root = new View[1];
- getInstrumentation().runOnMainSync(() ->
- root[0] = LayoutInflater.from(this.context)
- .inflate(R.layout.activity_device_info, null));
- return root[0];
- }
-
- @Test
- public void theScreenStillInflates() {
- assertNotNull(this.inflateDeviceInfo());
- }
-
- /** The id {@code DeviceInfoActivity.showLiveBattery} looks up has to resolve, or the row is
- * simply never shown and nothing fails. */
- @Test
- public void theLiveBatteryRowExists() {
- assertNotNull(this.inflateDeviceInfo().findViewById(R.id.device_settings_live_battery));
- }
-
- @Test
- public void theLiveBatteryRowIsHiddenUntilTheTagIsHeard() {
- final View row = this.inflateDeviceInfo().findViewById(R.id.device_settings_live_battery);
- assertEquals("an unheard tag must show no battery row at all, not an empty one",
- View.GONE, row.getVisibility());
- }
-
- /** Shown, it has to occupy real space rather than measuring to nothing. */
- @Test
- public void theLiveBatteryRowHasRealSizeOnceShown() {
- final int[] size = new int[2];
-
- getInstrumentation().runOnMainSync(() -> {
- final View screen = LayoutInflater.from(this.context)
- .inflate(R.layout.activity_device_info, null);
- final View row = screen.findViewById(R.id.device_settings_live_battery);
- row.setVisibility(View.VISIBLE);
-
- screen.measure(
- View.MeasureSpec.makeMeasureSpec(SCREEN_WIDTH_PX, View.MeasureSpec.EXACTLY),
- View.MeasureSpec.makeMeasureSpec(2400, View.MeasureSpec.EXACTLY));
- screen.layout(0, 0, SCREEN_WIDTH_PX, 2400);
-
- size[0] = row.getMeasuredWidth();
- size[1] = row.getMeasuredHeight();
- });
-
- assertTrue("row measured " + size[0] + "x" + size[1], size[0] > 0 && size[1] > 0);
- }
-
- /**
- * It sits outside the debug panel, which is the whole point. Inside it, the reading
- * would be invisible to everyone who has not turned debug data on, and this row exists
- * because a live reading is worth showing to everybody.
- */
- @Test
- public void theLiveBatteryRowIsNotInsideTheDebugPanel() {
- final View screen = this.inflateDeviceInfo();
- final View debugPanel = screen.findViewById(R.id.device_debug_info);
- final View row = screen.findViewById(R.id.device_settings_live_battery);
-
- assertNotNull(debugPanel);
- assertTrue("the live reading must not be gated behind the debug switch",
- ((ViewGroup) debugPanel).findViewById(R.id.device_settings_live_battery) == null);
- assertNotNull(row);
- }
-
- /** Half of what breaks only breaks in one mode. */
- @Test
- public void theRowSurvivesDarkMode() {
- final Configuration night = new Configuration(
- this.context.getResources().getConfiguration());
- night.uiMode = Configuration.UI_MODE_NIGHT_YES | Configuration.UI_MODE_TYPE_NORMAL;
-
- final Context darkContext = new ContextThemeWrapper(
- this.context.createConfigurationContext(night), R.style.Theme_OpenTagViewer);
-
- final View[] row = new View[1];
- getInstrumentation().runOnMainSync(() -> row[0] = LayoutInflater.from(darkContext)
- .inflate(R.layout.activity_device_info, null)
- .findViewById(R.id.device_settings_live_battery));
-
- assertNotNull(row[0]);
- assertEquals(View.GONE, row[0].getVisibility());
- }
-
- /**
- * The short battery words are what the row and the tag card show. The debug panel's own
- * strings spell out percentage ranges and a caveat, which is right there and far too long
- * for a one-line row - so this pins that they stayed short.
- */
- @Test
- public void theShortBatteryWordsStayShortEnoughForARow() {
- for (final int id : new int[] {
- R.string.battery_short_full,
- R.string.battery_short_medium,
- R.string.battery_short_low,
- R.string.battery_short_very_low,
- }) {
- final String word = this.context.getString(id);
- assertTrue("\"" + word + "\" is too long for a tag card line", word.length() <= 20);
- }
- }
-
- /** The card line reads e.g. "Nearby · Battery full", so the format has to take the word. */
- @Test
- public void theNearbyLineFormatsWithABatteryWord() {
- final String line = this.context.getString(R.string.nearby_now_with_battery,
- this.context.getString(R.string.battery_short_low));
-
- assertTrue("the battery word should appear in the line: " + line,
- line.contains(this.context.getString(R.string.battery_short_low)));
- }
-}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index 0661d88d..362c7574 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -71,6 +71,7 @@
import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository;
import dev.wander.android.opentagviewer.db.repo.UserSettingsRepository;
import dev.wander.android.opentagviewer.db.repo.model.BeaconData;
+import dev.wander.android.opentagviewer.db.repo.model.LastSightingData;
import dev.wander.android.opentagviewer.db.repo.model.UserSettings;
import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase;
import dev.wander.android.opentagviewer.db.room.entity.Import;
@@ -100,6 +101,15 @@ public class DeviceInfoActivity extends AppCompatActivity
private static final int PERMISSION_REQUEST_PLAY_SOUND_NEARBY = 1001;
+ /**
+ * How often the "Last seen" row is redrawn while the tag is quiet.
+ *
+ *
Matched to the coarsest unit {@code DateUtils} is asked for here, a minute: redrawing
+ * faster changes nothing on screen, and redrawing slower would leave the row a minute behind
+ * for someone watching it.
+ */
+ private static final long LAST_SEEN_REFRESH_MS = TimeUnit.MINUTES.toMillis(1);
+
private static final double DEFAULT_LONGITUDE = 0d;
private static final double DEFAULT_LATITUDE = 0d;
private static final float DEFAULT_ZOOM = 16.0f;
@@ -170,6 +180,17 @@ public class DeviceInfoActivity extends AppCompatActivity
/** The pending retry after the scan died mid-session - see {@link #onNearbyWatchEnded}. */
private Disposable nearbyWatchRetryDisposable;
+ /** The in-flight read of the stored sighting - see {@link #showWhatWasHeardOverBluetooth}. */
+ private Disposable lastSightingLookup;
+
+ /**
+ * Redraws the "Last seen" row while the tag is quiet, so its age keeps up with the clock.
+ *
+ *
Only runs in that state. While the tag is audible the row reads "just now" and every
+ * advertisement rewrites it anyway; once there is nothing stored the row is not on screen.
+ */
+ private Disposable lastSeenTicker;
+
/** The one place a Bluetooth sighting is persisted - see {@link AccessorySightingPersister}. */
private AccessorySightingPersister sightingPersister;
@@ -634,6 +655,7 @@ private void hideEmojiMenu() {
protected void onResume() {
super.onResume();
this.startWatchingForThisTag();
+ this.showWhatWasHeardOverBluetooth();
}
@Override
@@ -662,7 +684,7 @@ private void startWatchingForThisTag() {
.watch(this.getApplicationContext(), Map.of(this.beaconId, accessoryJson))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
- this::showLiveBattery,
+ this::showLiveSighting,
error -> Log.w(TAG, "Nearby watch ended for beaconId=" + this.beaconId, error),
this::onNearbyWatchEnded);
}
@@ -690,6 +712,14 @@ private void stopWatchingForThisTag() {
this.liveBatteryExpiry.dispose();
}
this.liveBatteryExpiry = null;
+ if (this.lastSightingLookup != null && !this.lastSightingLookup.isDisposed()) {
+ this.lastSightingLookup.dispose();
+ }
+ this.lastSightingLookup = null;
+ if (this.lastSeenTicker != null && !this.lastSeenTicker.isDisposed()) {
+ this.lastSeenTicker.dispose();
+ }
+ this.lastSeenTicker = null;
if (this.nearbyWatchRetryDisposable != null
&& !this.nearbyWatchRetryDisposable.isDisposed()) {
this.nearbyWatchRetryDisposable.dispose();
@@ -698,29 +728,125 @@ private void stopWatchingForThisTag() {
}
/**
- * Shows the battery level the tag just reported over the air.
+ * Shows what the tag is saying right now: heard just now, this strong, this much battery.
*
- *
Appears only once the tag has actually been heard, and never falls back to the iCloud
- * value: the whole reason this row is outside the debug panel is that it cannot be stale, so
- * quietly filling it from the record that can would defeat it. The debug row keeps that
- * value, with its caveat.
+ *
Never falls back to the iCloud value for the battery row, and never merges with it. The
+ * whole reason this section is outside the record's own fields is that it says where its
+ * numbers came from; quietly filling one of them from the other source would defeat that.
+ * The debug row keeps the record's value, with its caveat.
*/
- private void showLiveBattery(final NearbyTagSighting sighting) {
- this.binding.setLiveBatteryLevel(this.getString(R.string.live_battery_with_signal,
- this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())),
- NearbyTagLabel.signalStrengthBars(sighting.getRssi())));
- this.findViewById(R.id.device_settings_live_battery).setVisibility(VISIBLE);
-
- // Every sighting restarts the expiry, so the row hides only once the tag has been
- // quiet for the whole window - see the field doc.
+ private void showLiveSighting(final NearbyTagSighting sighting) {
+ // A stored reading on its way back from the database would land on top of this one and
+ // relabel a tag we can hear right now as last heard some minutes ago. The live value
+ // always wins, so the read that was going to contradict it is dropped rather than raced.
+ if (this.lastSightingLookup != null && !this.lastSightingLookup.isDisposed()) {
+ this.lastSightingLookup.dispose();
+ }
+ if (this.lastSeenTicker != null && !this.lastSeenTicker.isDisposed()) {
+ this.lastSeenTicker.dispose();
+ }
+
+ this.binding.setBleLastSeen(this.getString(R.string.seen_just_now));
+ this.binding.setBleSignalStrength(NearbyTagLabel.signalStrengthBars(sighting.getRssi()));
+ this.binding.setBleBatteryLevel(
+ this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())));
+
+ this.showBluetoothSection(true);
+
+ // Every sighting restarts the expiry, so the section only stops claiming to be current
+ // once the tag has been quiet for the whole window - see the field doc.
if (this.liveBatteryExpiry != null && !this.liveBatteryExpiry.isDisposed()) {
this.liveBatteryExpiry.dispose();
}
this.liveBatteryExpiry = Observable
.timer(NearbyTagSightings.FRESH_FOR_MS, TimeUnit.MILLISECONDS,
AndroidSchedulers.mainThread())
- .subscribe(tick -> this.findViewById(R.id.device_settings_live_battery)
- .setVisibility(GONE));
+ .subscribe(tick -> this.showWhatWasHeardOverBluetooth());
+ }
+
+ /**
+ * Falls back to what the tag last said, with its age, once it has gone quiet.
+ *
+ *
What the section says when the tag is out of earshot. The live reading expires
+ * because a tag carried away stops being here - see {@link NearbyTagSightings} - but the
+ * battery it reported on the way out is still the best answer anybody has, and for a user
+ * with no Apple device it is the only one: the record's own field is updated by Apple's
+ * devices and stays at "not yet reported" forever otherwise. So the claim is weakened rather
+ * than withdrawn, from "this is the level" to "this is the level when it was last heard".
+ *
+ *
The signal row goes, the battery row stays. That split is the point of splitting
+ * them. A battery level from an hour ago is still roughly the battery level; a signal
+ * strength from an hour ago describes a distance to a tag that is no longer there, and there
+ * is no wording that makes it useful. So it is withdrawn rather than dated.
+ *
+ *
The age on "Last seen" is not decoration either. Without it this is the same trap as the
+ * debug panel's iCloud value: a battery word with no date reads as current, and "full" from a
+ * tag last heard in March is worse than an empty row.
+ *
+ *
Hides the whole section when there is nothing stored, which is a tag this phone has
+ * never heard - a new install, or one whose tags have only ever been seen over the network.
+ */
+ private void showWhatWasHeardOverBluetooth() {
+ if (this.lastSightingLookup != null && !this.lastSightingLookup.isDisposed()) {
+ this.lastSightingLookup.dispose();
+ }
+
+ this.lastSightingLookup = this.beaconRepo.getLastSighting(this.beaconId)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(stored -> {
+ if (stored.isEmpty()) {
+ this.showBluetoothSection(false);
+ return;
+ }
+
+ final LastSightingData sighting = stored.get();
+ this.binding.setBleBatteryLevel(this.getString(
+ NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())));
+ this.showAgeOfLastSighting(sighting.getHeardAtMs());
+ this.showBluetoothSection(true, false);
+
+ // "3 minutes ago" is only true for a minute. Nothing else on this screen
+ // redraws while it sits open, so without a tick the row would freeze at
+ // whatever it said when the tag went quiet and keep saying it for as long as
+ // somebody watched - which is precisely the staleness this row exists to
+ // report rather than commit.
+ this.lastSeenTicker = Observable
+ .interval(LAST_SEEN_REFRESH_MS, LAST_SEEN_REFRESH_MS,
+ TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
+ .subscribe(tick -> this.showAgeOfLastSighting(
+ sighting.getHeardAtMs()));
+ }, error -> Log.w(TAG, "Could not read the last sighting for beaconId="
+ + this.beaconId, error));
+ }
+
+ /** Writes the "Last seen" row, e.g. "3 minutes ago", from a wall-clock timestamp. */
+ private void showAgeOfLastSighting(final long heardAtMs) {
+ this.binding.setBleLastSeen(DateUtils.getRelativeTimeSpanString(
+ heardAtMs, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString());
+ }
+
+ /** The section with every row, for a tag being heard right now. */
+ private void showBluetoothSection(final boolean visible) {
+ this.showBluetoothSection(visible, visible);
+ }
+
+ /**
+ * Shows or hides the "Over Bluetooth" section - its divider, its heading and its rows
+ * together, so it never appears as a heading with nothing under it.
+ *
+ * @param signalToo whether the signal row is among them. False once the tag has gone quiet:
+ * see {@link #showWhatWasHeardOverBluetooth} for why that one row is
+ * withdrawn while the others are merely dated.
+ */
+ private void showBluetoothSection(final boolean visible, final boolean signalToo) {
+ final int visibility = visible ? VISIBLE : GONE;
+
+ this.findViewById(R.id.device_ble_divider).setVisibility(visibility);
+ this.findViewById(R.id.device_ble_header).setVisibility(visibility);
+ this.findViewById(R.id.device_settings_ble_last_seen).setVisibility(visibility);
+ this.findViewById(R.id.device_settings_ble_battery).setVisibility(visibility);
+ this.findViewById(R.id.device_settings_ble_signal)
+ .setVisibility(signalToo ? VISIBLE : GONE);
}
@Override
diff --git a/app/src/main/res/layout/activity_device_info.xml b/app/src/main/res/layout/activity_device_info.xml
index bbfd1545..cf589338 100644
--- a/app/src/main/res/layout/activity_device_info.xml
+++ b/app/src/main/res/layout/activity_device_info.xml
@@ -102,10 +102,25 @@
name="batteryLevel"
type="String" />
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
niedrig
kritisch
Akku
- %1$s · Signal %2$s
+ Über Bluetooth
+ Zuletzt gehört
+ Signal
+ gerade eben
\ No newline at end of file
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index bd64b5c7..846e00de 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -334,5 +334,8 @@ You can set this up now, or any time later from Settings.
critical
Battery
Nearby (%2$s) · Battery %1$s
- %1$s · signal %2$s
+ Over Bluetooth
+ Last seen
+ Signal
+ just now
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index de997dbb..1132b540 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -334,5 +334,8 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.<
critique
Batterie
À proximité (%2$s) · Batterie %1$s
- %1$s · signal %2$s
+ Par Bluetooth
+ Dernière détection
+ Signal
+ à l\'instant
\ No newline at end of file
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 3e481dcd..e34acba8 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -334,5 +334,8 @@
危険
バッテリー
近くにあります(%2$s)· 電池 %1$s
- %1$s · 信号 %2$s
+ Bluetooth 経由
+ 最後の受信
+ 信号強度
+ たった今
\ No newline at end of file
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 0ec371b7..872f797e 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -334,5 +334,8 @@
위험
배터리
근처에 있음 (%2$s) · 배터리 %1$s
- %1$s · 신호 %2$s
+ 블루투스로 수신
+ 마지막 수신
+ 신호 세기
+ 방금
\ No newline at end of file
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 27cde69d..0c5bc62d 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -334,5 +334,8 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen.
kritiek
Batterij
In de buurt (%2$s) · Batterij %1$s
- %1$s · signaal %2$s
+ Via bluetooth
+ Laatst gehoord
+ Signaal
+ zojuist
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index dc870c7a..8beaabfa 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -334,5 +334,8 @@
критический
Батарея
Рядом (%2$s) · Батарея %1$s
- %1$s · сигнал %2$s
+ По Bluetooth
+ Последний сигнал
+ Сигнал
+ только что
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 5844eb40..96c820d6 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -334,5 +334,8 @@
极低
电量
在附近(%2$s)· 电量%1$s
- %1$s · 信号%2$s
+ 通过蓝牙
+ 最后收到
+ 信号强度
+ 刚刚
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 165294ea..cf899749 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -334,5 +334,8 @@
極低
電量
在附近(%2$s)· 電量%1$s
- %1$s · 訊號%2$s
+ 透過藍牙
+ 最後收到
+ 訊號強度
+ 剛剛
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d0e77e02..7c4e92a0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -366,5 +366,8 @@ You can set this up now, or any time later from Settings.
low
critical
Battery
- %1$s · signal %2$s
+ Over Bluetooth
+ Last seen
+ Signal
+ just now
From 6f0757224a6f09b7b5cd036b5bbd1a227f59204a Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Mon, 24 Aug 2026 14:08:49 +0200
Subject: [PATCH 32/61] Say "just now" rather than "0 minutes ago" on a fresh
sighting
DateUtils rounds to the resolution it is given, so for the first minute
after a tag goes quiet the "Last seen" row read "0 minutes ago". That is
not a corner case: the live window is thirty seconds, so it is what
everybody sees on the way from hearing a tag to not hearing it. It is
also not really English.
Asking the formatter for second resolution instead would say "43 seconds
ago", a precision this row cannot keep - it redraws once a minute.
Under a minute it now says "just now", the same words the live row uses,
which is honest: the tag really was heard just now. What separates the
two states on screen is the signal row, which is there only while the
reading is live.
---
.../opentagviewer/DeviceInfoActivity.java | 24 ++++++++++++++++---
1 file changed, 21 insertions(+), 3 deletions(-)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index 362c7574..8f37052b 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -819,10 +819,28 @@ private void showWhatWasHeardOverBluetooth() {
+ this.beaconId, error));
}
- /** Writes the "Last seen" row, e.g. "3 minutes ago", from a wall-clock timestamp. */
+ /**
+ * Writes the "Last seen" row, e.g. "3 minutes ago", from a wall-clock timestamp.
+ *
+ * Under a minute it says "just now" rather than what the formatter returns, which
+ * is "0 minutes ago". That is the reading for the first half-minute after a tag goes quiet -
+ * the live window is thirty seconds - so it is not a rare corner, it is what everybody sees
+ * on the way from hearing the tag to not hearing it. "0 minutes ago" is also not really
+ * English. Asking the formatter for second resolution instead would say "43 seconds ago",
+ * which is a precision this row cannot keep: it redraws once a minute.
+ *
+ *
The same words as the live row, and that is honest - the tag really was heard just now.
+ * What separates the two states on screen is the signal row, which is there only while the
+ * reading is live.
+ */
private void showAgeOfLastSighting(final long heardAtMs) {
- this.binding.setBleLastSeen(DateUtils.getRelativeTimeSpanString(
- heardAtMs, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString());
+ final long ageMs = System.currentTimeMillis() - heardAtMs;
+
+ this.binding.setBleLastSeen(ageMs < DateUtils.MINUTE_IN_MILLIS
+ ? this.getString(R.string.seen_just_now)
+ : DateUtils.getRelativeTimeSpanString(
+ heardAtMs, System.currentTimeMillis(),
+ DateUtils.MINUTE_IN_MILLIS).toString());
}
/** The section with every row, for a tag being heard right now. */
From d2da7b36137b5a8aa818545bdc15b0b93d86d2d4 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Tue, 25 Aug 2026 11:57:01 +0200
Subject: [PATCH 33/61] Stop one unalignable accessory from costing every tag
its scan
Turning on "show my own Apple devices" puts a phone in the tag list. A
phone has no rolling-key alignment and never gains one, so its candidate
window spans its whole life: measured on a real one, switched off, 39636
indices.
NearbyTagIndex.rebuild asks the resolver per accessory in a loop, and the
derivation is blocking with no interruption point, so the loop never
reached the accessories after it. The scan was therefore never started at
all. Nothing failed anywhere: every real tag simply stopped being seen,
and the watcher logged nothing to say why. Measured at 124% CPU across
two Rx threads for as long as the screen stayed open.
The width is set by how stale the alignment is rather than by whether
there is one, which is worth knowing before reading the limit. On desktop
CPython, several times faster than Chaquopy on a phone: 144 indices and
0.5s at a day, 2,928 and 9.3s at a month, 38,448 and 121.9s at 400 days.
About 3.2ms per index, linear.
currentMacAddresses now measures the window first - with _isAlignmentWide,
which the fetch path already used for the same question - and refuses
above a thousand indices, roughly a week of staleness. It has to refuse
there rather than in the caller: by the time a caller could measure the
answer, the work is already done.
The cost is real and is stated in the tests: a tag the network has not
found for over a week loses BLE matching too. The alternative is not a
slower scan, it is no scan for anybody.
The better fix is to bound the range instead of refusing it, taking the
newest N indices. A tag advertising right now has been running, so its
true index tracks the wall clock and sits at the top of the window, while
the bottom belongs to a tag that was off for months and is not
advertising anyway. That wants a max_indices on current_mac_addresses in
the pinned FindMy.py rather than a second key walk here, so it is not in
this commit.
Both halves are here together on purpose. None was always a documented
answer from the resolver, and rebuild dereferenced it - so refusing in
Python without guarding in Java would replace a stalled rebuild with a
thrown one, which kills the same watch just as quietly.
---
.../opentagviewer/ble/NearbyTagIndex.java | 30 ++++++++++++---
.../opentagviewer/ble/NearbyTagIndexTest.java | 37 +++++++++++++++++++
2 files changed, 61 insertions(+), 6 deletions(-)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java
index fb6be1cc..8b83c828 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java
@@ -1,9 +1,10 @@
package dev.wander.android.opentagviewer.ble;
+import android.util.Log;
+
import androidx.annotation.Nullable;
import java.util.HashMap;
-import java.util.Set;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@@ -36,6 +37,8 @@
* it would drop matches, which presents as "the tag is never nearby".
*/
public final class NearbyTagIndex {
+ private static final String TAG = NearbyTagIndex.class.getSimpleName();
+
/**
* How long a built index is trusted.
@@ -59,9 +62,10 @@ public boolean isStale(final long nowMs) {
*
*
Blocking, once per tag. Call it off the main thread.
*
- * @param accessoryJsonByBeaconId the persisted accessory JSON per beacon. An entry whose
- * JSON is null or unreadable is skipped rather than failing
- * the rebuild: a tag that has not been backfilled yet should
+ * @param accessoryJsonByBeaconId the persisted accessory JSON per beacon. An entry the
+ * resolver cannot answer for - unreadable JSON, or a
+ * candidate window too wide to be worth deriving - is
+ * skipped rather than failing the rebuild: such a tag should
* cost only its own sightings, not everyone else's.
*/
public void rebuild(
@@ -73,8 +77,22 @@ public void rebuild(
for (final Map.Entry entry : accessoryJsonByBeaconId.entrySet()) {
// Only the address is wanted here; the key index each maps to is not this class's
// business - see AccessoryMacResolver#recordSeen on why only Python may act on it.
- final Set macs = resolver.currentMacAddresses(entry.getValue()).keySet();
- for (final String mac : macs) {
+ final Map candidates = resolver.currentMacAddresses(entry.getValue());
+
+ // **Null is a documented answer, not a broken one, and it must not stop the loop.**
+ // The resolver returns it for an accessory it cannot read, and for one whose
+ // candidate window is too wide to derive - which is what an owner's own Apple
+ // device looks like, since a phone has no rolling-key alignment. Dereferencing it
+ // threw, and the throw left this whole watch dead: one entry cost every other
+ // entry its sightings, which is exactly what the parameter note below forbids. It
+ // presents as no tag ever being nearby, with nothing failing anywhere to say why.
+ if (candidates == null) {
+ Log.d(TAG, "No candidate addresses for beaconId=" + entry.getKey()
+ + "; leaving it out of the index rather than dropping the rest");
+ continue;
+ }
+
+ for (final String mac : candidates.keySet()) {
if (mac != null) {
// Upper-cased on the way in so lookups need no normalisation per scan
// result, which is the hot path. Android reports uppercase and FindMy.py
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexTest.java
index 1abf1984..2b7b0af5 100644
--- a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexTest.java
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexTest.java
@@ -132,6 +132,43 @@ public void oneUnresolvableTagDoesNotCostTheOthers() {
assertEquals(1, index.size());
}
+ /**
+ * The resolver's documented way of saying "not from me": null, not an empty map.
+ *
+ * This is the one that got out. Turning on "show my own Apple devices" put a
+ * phone in the list, and a phone has no rolling-key alignment, so the resolver refused it -
+ * correctly. The refusal was then dereferenced, the throw killed the whole rebuild, and the
+ * scan never started: every real tag stopped being seen, with nothing failing anywhere to
+ * say why. One entry may only ever cost its own sightings.
+ */
+ @Test
+ public void aTagTheResolverRefusesDoesNotCostTheOthers() {
+ final Map tags = new HashMap<>();
+ tags.put(KEYS, "good");
+ tags.put(BIKE, "refused");
+
+ final AccessoryMacResolver refusesOne = json ->
+ "good".equals(json) ? macs("AA:AA:AA:AA:AA:01") : null;
+
+ final NearbyTagIndex index = new NearbyTagIndex();
+ index.rebuild(tags, refusesOne, 0L);
+
+ assertEquals(KEYS, index.beaconIdFor("AA:AA:AA:AA:AA:01"));
+ assertEquals(1, index.size());
+ }
+
+ /** And when every tag is refused, that is an empty index rather than a thrown rebuild. */
+ @Test
+ public void aResolverThatRefusesEverythingLeavesAnEmptyIndexRatherThanThrowing() {
+ final NearbyTagIndex index = new NearbyTagIndex();
+
+ index.rebuild(twoTags(), json -> null, 5_000L);
+
+ assertEquals(0, index.size());
+ assertFalse("a rebuild that ran must count as built, or it repeats every scan result",
+ index.isStale(5_000L));
+ }
+
@Test
public void resolvesEachTagExactlyOncePerRebuild() {
final AtomicInteger calls = new AtomicInteger();
From cef56fb08baff7f720a4cefd6a9fa237be543e7d Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Tue, 25 Aug 2026 13:11:40 +0200
Subject: [PATCH 34/61] Show the raw BLE status byte in the debug panel
The "Over Bluetooth" battery reading decodes bits 6-7 of the status byte
per Apple's Table 5-5. Every accessory this was built and tested against
is third-party, and MFi accessories follow that table.
An AirTag does not. LocationReportFields already records the problem for
the copy of this byte that arrives in a network report: an AirTag
advertises 0x90, which fails the same table's marker bit and sets a
reserved one, and decoding it anyway reads "battery low" for a tag whose
own record says full. Adam Catley's teardown records 0x10, breaking the
same two rules. That class therefore decodes only a conforming byte and
otherwise shows the number alone; the BLE parser has no such gate and
reads the bits unconditionally.
Rather than guess which way to fix that, this shows the byte, so the
question can be settled against real hardware. Rendered by
LocationReportFields.status, deliberately: the same rendering a network
report's copy gets, so the two are directly comparable, and it appends
the Table 5-5 reading only when the byte earns one.
The value is already stored per tag, so this is a display of something
the app has rather than anything new to collect. Debug panel because a
raw byte is diagnostics, and untranslated like the rest of that panel.
---
.../opentagviewer/DeviceInfoActivity.java | 24 ++++++++++++++
.../main/res/layout/activity_device_info.xml | 31 +++++++++++++++++++
app/src/main/res/values/strings.xml | 1 +
3 files changed, 56 insertions(+)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index 8f37052b..70d0199d 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -81,6 +81,7 @@
import dev.wander.android.opentagviewer.util.android.WebLink;
import dev.wander.android.opentagviewer.util.parse.BatteryLevelDescription;
import dev.wander.android.opentagviewer.util.parse.BeaconDataParser;
+import dev.wander.android.opentagviewer.util.parse.LocationReportFields;
import dev.wander.android.opentagviewer.util.rx.WideScanBackoff;
import dev.wander.android.opentagviewer.python.AppDependencies;
import dev.wander.android.opentagviewer.ui.BeaconIcon;
@@ -750,6 +751,7 @@ private void showLiveSighting(final NearbyTagSighting sighting) {
this.binding.setBleSignalStrength(NearbyTagLabel.signalStrengthBars(sighting.getRssi()));
this.binding.setBleBatteryLevel(
this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())));
+ this.showStatusByteForDebugging(sighting.getStatusByte());
this.showBluetoothSection(true);
@@ -802,6 +804,7 @@ private void showWhatWasHeardOverBluetooth() {
final LastSightingData sighting = stored.get();
this.binding.setBleBatteryLevel(this.getString(
NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())));
+ this.showStatusByteForDebugging(sighting.getStatusByte());
this.showAgeOfLastSighting(sighting.getHeardAtMs());
this.showBluetoothSection(true, false);
@@ -819,6 +822,27 @@ private void showWhatWasHeardOverBluetooth() {
+ this.beaconId, error));
}
+ /**
+ * Puts the raw status byte the battery reading came out of into the debug panel.
+ *
+ * To be measured against, not read as a battery level. The section above decodes
+ * bits 6-7 of this byte per Apple's Table 5-5, which is right for an MFi accessory and wrong
+ * for an AirTag: {@link LocationReportFields} records one advertising {@code 0x90}, which
+ * fails that table's marker and reserved bits, and notes that decoding it anyway reads "low"
+ * for a tag whose own record says full. Every accessory this feature was built against is
+ * third-party, so the reading has never been checked against hardware that does not follow
+ * the specification.
+ *
+ *
Rendered by {@link LocationReportFields#status}, deliberately: it is the same rendering
+ * a network report's copy of this byte gets, so the two can be compared directly, and it
+ * appends a Table 5-5 reading only to a byte that actually conforms - which is the question
+ * this row exists to answer.
+ */
+ private void showStatusByteForDebugging(final int statusByte) {
+ this.binding.setBleStatusByte(LocationReportFields.status(statusByte));
+ this.findViewById(R.id.settings_debug_ble_status_byte).setVisibility(VISIBLE);
+ }
+
/**
* Writes the "Last seen" row, e.g. "3 minutes ago", from a wall-clock timestamp.
*
diff --git a/app/src/main/res/layout/activity_device_info.xml b/app/src/main/res/layout/activity_device_info.xml
index cf589338..067edb84 100644
--- a/app/src/main/res/layout/activity_device_info.xml
+++ b/app/src/main/res/layout/activity_device_info.xml
@@ -123,6 +123,12 @@
name="bleBatteryLevel"
type="String" />
+
+
+
@@ -643,6 +649,31 @@
app:sectionSubtitle="@{batteryLevel}"
app:title="@{@string/debug_battery_level}" />
+
+
+
Type
Unknown
Battery level
+ BLE status byte
Device Model
Pairing Date
Product Id
From 7be609d6d92175bd1788fce810f39fead05a2229 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Tue, 25 Aug 2026 15:19:49 +0200
Subject: [PATCH 35/61] Say that the null-resolver guards are latent, not
observed
Both comments claimed the dereference had thrown and killed the watch.
It never has: the Chaquopy resolver maps Python's None to an empty map,
so no build reaches the null path. The observed failure was the blocking
derivation, which is a different thing entirely.
The guards stay - the interface permits null and a test double or a
future implementation may return it - but a comment that reports a crash
nobody has seen will be trusted years later by somebody with no way to
check it.
---
.../android/opentagviewer/ble/NearbyTagIndex.java | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java
index 8b83c828..d2cb8de3 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java
@@ -80,12 +80,14 @@ public void rebuild(
final Map candidates = resolver.currentMacAddresses(entry.getValue());
// **Null is a documented answer, not a broken one, and it must not stop the loop.**
- // The resolver returns it for an accessory it cannot read, and for one whose
- // candidate window is too wide to derive - which is what an owner's own Apple
- // device looks like, since a phone has no rolling-key alignment. Dereferencing it
- // threw, and the throw left this whole watch dead: one entry cost every other
- // entry its sightings, which is exactly what the parameter note below forbids. It
- // presents as no tag ever being nearby, with nothing failing anywhere to say why.
+ // The interface permits it for an accessory the resolver cannot read, and for one
+ // whose candidate window is too wide to derive - which is what an owner's own Apple
+ // device looks like, since a phone has no rolling-key alignment.
+ //
+ // Latent rather than observed: the Chaquopy implementation maps Python's None to an
+ // empty map, so no build has actually thrown here. A different implementation, or a
+ // test double, may return null as the signature allows - and then one entry would
+ // cost every other entry its sightings, which is what the parameter note forbids.
if (candidates == null) {
Log.d(TAG, "No candidate addresses for beaconId=" + entry.getKey()
+ "; leaving it out of the index rather than dropping the rest");
From bbbb630005ec8f5b3fb31dc8ab178d708b102085 Mon Sep 17 00:00:00 2001
From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Date: Tue, 25 Aug 2026 18:05:35 +0200
Subject: [PATCH 36/61] Record where the phone was when it heard a tag
A BLE sighting says the tag was within roughly ten metres of this phone
at that moment. That is a far tighter claim than a network report, which
describes where some stranger's iPhone thought it was - measured in the
SEEMOO paper at 121m reported against 81m actual while walking, and 145m
against 581m in a car. Until now the app threw it away.
Sightings now write a LocationReport of their own, with a provenance
column saying whether a row came from Apple or from this phone. Same
table on purpose: the marker, the "last updated" line, the navigate
button, the history and the CSV export all read from there, and a second
table would mean teaching every one of them about a second source. The
column is what keeps the two distinguishable, and the export is the case
where that plainly matters - otherwise somebody's own positions sit
unlabelled among Apple's.
horizontal_accuracy is the fix's own accuracy rather than an invented
number, so the two sources stay comparable on one scale. confidence stays
zero: Apple's byte is one this app deliberately does not interpret, and
there is nothing honest to put there.
**Not every sighting earns a row.** Sightings arrive every second or two
and the callback fires once a minute, which is right for a battery
reading that gets overwritten and wrong for a row that is kept and
reverse-geocoded when shown. A tag beside somebody all evening would
write several hundred points describing one spot. LocalFixWorthKeeping
writes when the phone has moved more than 25m or 15 minutes have passed,
and measures distance on the globe rather than the grid - a degree of
longitude is shorter this far north, and the flat approximation gets
worse the further from the equator somebody lives.
The position comes from the cached fix rather than a fresh one: asking
per sighting would be the most expensive thing on a passive scan path,
and hearing the tag has already established the distance that matters. No
fix, no permission or no location means no row, silently.
The six existing fixtures that build a LocationReport now say
PROVENANCE_APPLE, which is what they always were.
---
.../8.json | 505 ++++++++++++++++++
...ccountRefreshKeepsWhatTheUserOwnsTest.java | 1 +
.../WritingDownWhereATagWasHeardTest.java | 196 +++++++
.../db/room/LatestReportPerBeaconTest.java | 1 +
.../OpenTagViewerDatabaseMigrationTest.java | 73 ++-
.../TheHistoryScreenDrawsTheDayTest.java | 1 +
.../ui/maps/AMapWithTagsOnIt.java | 1 +
.../ui/maps/TheMapDrawsWhatIsStoredTest.java | 1 +
.../TheDeviceListNoticesNewLocationsTest.java | 1 +
.../AccessorySightingPersister.java | 42 +-
.../opentagviewer/DeviceInfoActivity.java | 4 +-
.../android/opentagviewer/MapsActivity.java | 4 +-
.../db/repo/BeaconRepository.java | 100 ++++
.../db/room/OpenTagViewerDatabase.java | 23 +-
.../db/room/dao/LocationReportDao.java | 12 +
.../db/room/entity/LocationReport.java | 30 ++
.../util/LocalFixWorthKeeping.java | 99 ++++
.../util/android/FusedPhoneLocation.java | 94 ++++
.../util/android/PhoneLocation.java | 49 ++
.../util/LocalFixWorthKeepingTest.java | 88 +++
20 files changed, 1313 insertions(+), 12 deletions(-)
create mode 100644 app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/8.json
create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/WritingDownWhereATagWasHeardTest.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/LocalFixWorthKeeping.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/android/FusedPhoneLocation.java
create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/android/PhoneLocation.java
create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/util/LocalFixWorthKeepingTest.java
diff --git a/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/8.json b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/8.json
new file mode 100644
index 00000000..120c8031
--- /dev/null
+++ b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/8.json
@@ -0,0 +1,505 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 8,
+ "identityHash": "fe546a06e22198add195b7760075cd65",
+ "entities": [
+ {
+ "tableName": "Import",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `version` TEXT, `imported_at` INTEGER NOT NULL, `exported_at` INTEGER NOT NULL, `source_user` TEXT, `via` TEXT)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "importedAt",
+ "columnName": "imported_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "exportedAt",
+ "columnName": "exported_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceUser",
+ "columnName": "source_user",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "exportedVia",
+ "columnName": "via",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "BeaconNamingRecord",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `version` TEXT, `content` TEXT, `is_removed` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "importId",
+ "columnName": "import_id",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isRemoved",
+ "columnName": "is_removed",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_BeaconNamingRecord_import_id",
+ "unique": false,
+ "columnNames": [
+ "import_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_BeaconNamingRecord_import_id` ON `${TABLE_NAME}` (`import_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "Import",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "import_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "OwnedBeacons",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `content` TEXT, `version` TEXT, `is_removed` INTEGER NOT NULL, `from_account` INTEGER NOT NULL, `fruitless_scans` INTEGER NOT NULL DEFAULT 0, `last_scan_at` INTEGER, `ignored_at` INTEGER, `accessory_json` TEXT, `alignment_plist` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "importId",
+ "columnName": "import_id",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isRemoved",
+ "columnName": "is_removed",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromAccount",
+ "columnName": "from_account",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fruitlessScans",
+ "columnName": "fruitless_scans",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "lastScanAt",
+ "columnName": "last_scan_at",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "ignoredAt",
+ "columnName": "ignored_at",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "accessoryJson",
+ "columnName": "accessory_json",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "alignmentPlist",
+ "columnName": "alignment_plist",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_OwnedBeacons_import_id",
+ "unique": false,
+ "columnNames": [
+ "import_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_OwnedBeacons_import_id` ON `${TABLE_NAME}` (`import_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "Import",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "import_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "LocationReport",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hash_id` TEXT NOT NULL, `beacon_id` TEXT NOT NULL, `published_at` INTEGER NOT NULL, `description` TEXT, `timestamp` INTEGER NOT NULL, `confidence` INTEGER NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `horizontal_accuracy` INTEGER NOT NULL, `status` INTEGER NOT NULL, `last_update` INTEGER NOT NULL, `provenance` TEXT NOT NULL DEFAULT 'apple', PRIMARY KEY(`hash_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "hashId",
+ "columnName": "hash_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "beaconId",
+ "columnName": "beacon_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publishedAt",
+ "columnName": "published_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "confidence",
+ "columnName": "confidence",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "horizontalAccuracy",
+ "columnName": "horizontal_accuracy",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdate",
+ "columnName": "last_update",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "provenance",
+ "columnName": "provenance",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'apple'"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "hash_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_LocationReport_hash_id_beacon_id_timestamp",
+ "unique": false,
+ "columnNames": [
+ "hash_id",
+ "beacon_id",
+ "timestamp"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_LocationReport_hash_id_beacon_id_timestamp` ON `${TABLE_NAME}` (`hash_id`, `beacon_id`, `timestamp`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "OwnedBeacons",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "beacon_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "DailyHistoryFetchRecord",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_start_time` INTEGER NOT NULL, `beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`day_start_time`, `beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "dayStartTime",
+ "columnName": "day_start_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "beaconId",
+ "columnName": "beacon_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdate",
+ "columnName": "last_update",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "day_start_time",
+ "beacon_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_DailyHistoryFetchRecord_beacon_id",
+ "unique": false,
+ "columnNames": [
+ "beacon_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_DailyHistoryFetchRecord_beacon_id` ON `${TABLE_NAME}` (`beacon_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "OwnedBeacons",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "beacon_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "UserBeaconOptions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, `ui_name` TEXT, `ui_emoji` TEXT, `ui_order` INTEGER, PRIMARY KEY(`beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "beaconId",
+ "columnName": "beacon_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdate",
+ "columnName": "last_update",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uiName",
+ "columnName": "ui_name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "uiEmoji",
+ "columnName": "ui_emoji",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "uiOrder",
+ "columnName": "ui_order",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "beacon_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "OwnedBeacons",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "beacon_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "LastBleSighting",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beacon_id` TEXT NOT NULL, `heard_at` INTEGER NOT NULL, `battery_level` TEXT NOT NULL, `status_byte` INTEGER NOT NULL, PRIMARY KEY(`beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "beaconId",
+ "columnName": "beacon_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "heardAt",
+ "columnName": "heard_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "batteryLevel",
+ "columnName": "battery_level",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "statusByte",
+ "columnName": "status_byte",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "beacon_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "OwnedBeacons",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "beacon_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fe546a06e22198add195b7760075cd65')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/AccountRefreshKeepsWhatTheUserOwnsTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/AccountRefreshKeepsWhatTheUserOwnsTest.java
index 3fe676a3..65d0b016 100644
--- a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/AccountRefreshKeepsWhatTheUserOwnsTest.java
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/AccountRefreshKeepsWhatTheUserOwnsTest.java
@@ -99,6 +99,7 @@ private void givenItHasSomeHistory() {
.horizontalAccuracy(83)
.status(144)
.lastUpdate(1_000L)
+ .provenance(LocationReport.PROVENANCE_APPLE)
.build());
}
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/WritingDownWhereATagWasHeardTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/WritingDownWhereATagWasHeardTest.java
new file mode 100644
index 00000000..c2671218
--- /dev/null
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/WritingDownWhereATagWasHeardTest.java
@@ -0,0 +1,196 @@
+package dev.wander.android.opentagviewer.db.repo;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import androidx.room.Room;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+
+import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase;
+import dev.wander.android.opentagviewer.db.room.entity.LocationReport;
+import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon;
+import dev.wander.android.opentagviewer.util.LocalFixWorthKeeping;
+
+/**
+ * A position this phone worked out for itself, stored beside the ones Apple's network sent.
+ *
+ * Same table, different claim. Everything that draws a tag reads from
+ * {@code LocationReport}, so a locally heard position has to land there to be of any use - but a
+ * row saying "a stranger's iPhone guessed the tag was somewhere around here" and one saying
+ * "this phone heard it from ten metres away" are not interchangeable, and the history gets
+ * exported. The {@code provenance} column is what keeps them apart.
+ */
+@RunWith(AndroidJUnit4.class)
+public class WritingDownWhereATagWasHeardTest {
+
+ private static final String A_TAG = "a-tag";
+ private static final String A_PLIST = "";
+
+ /** Ilvesheim, where the tags behind this feature actually live. */
+ private static final double LAT = 49.4767;
+ private static final double LON = 8.5622;
+
+ private static final long NOON = 1_700_000_000_000L;
+ private static final int A_STATUS_BYTE = 0x20;
+
+ private OpenTagViewerDatabase db;
+ private BeaconRepository repo;
+
+ @Before
+ public void openAnInMemoryDatabase() {
+ this.db = Room.inMemoryDatabaseBuilder(
+ getInstrumentation().getTargetContext(), OpenTagViewerDatabase.class)
+ .allowMainThreadQueries()
+ .build();
+
+ this.repo = new BeaconRepository(this.db, (plist, alignment) -> "{\"type\":\"accessory\"}");
+
+ this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder()
+ .id(A_TAG).content(A_PLIST).accessoryJson("{\"type\":\"accessory\"}")
+ .version("0.0.2").fromAccount(false).isRemoved(false).build());
+ }
+
+ @After
+ public void closeIt() {
+ this.db.close();
+ }
+
+ private boolean record(final double lat, final double lon, final long accuracy, final long at) {
+ return this.repo.recordLocalSighting(A_TAG, lat, lon, accuracy, A_STATUS_BYTE, at)
+ .blockingFirst();
+ }
+
+ private List allReports() {
+ return this.db.locationReportDao()
+ .getInTimeRange(A_TAG, NOON - 86_400_000L, NOON + 86_400_000L);
+ }
+
+ @Test
+ public void aSightingBecomesALocationReportMarkedAsLocal() {
+ assertTrue(this.record(LAT, LON, 8, NOON));
+
+ final List reports = this.allReports();
+ assertEquals(1, reports.size());
+
+ final LocationReport report = reports.get(0);
+ assertEquals(LocationReport.PROVENANCE_LOCAL, report.provenance);
+ assertEquals(LAT, report.latitude, 0.00001);
+ assertEquals(LON, report.longitude, 0.00001);
+ assertEquals(NOON, report.timestamp);
+ }
+
+ /**
+ * The accuracy is the fix's own, not a guess.
+ *
+ * It is the field anything comparing two reports reads, and a locally heard position is
+ * usually an order of magnitude tighter than a network one. Inventing a number here would
+ * either throw that advantage away or claim precision the fix never had.
+ */
+ @Test
+ public void theFixesOwnAccuracyIsWhatGetsStored() {
+ this.record(LAT, LON, 8, NOON);
+
+ assertEquals(8, this.allReports().get(0).horizontalAccuracy);
+ }
+
+ /** The status byte the tag broadcast rides along, the same field an Apple report carries. */
+ @Test
+ public void theAdvertisedStatusByteIsKeptOnTheReport() {
+ this.record(LAT, LON, 8, NOON);
+
+ assertEquals(A_STATUS_BYTE, this.allReports().get(0).status);
+ }
+
+ /**
+ * The rule that keeps a tag on a desk from filling the history. Sightings arrive every
+ * couple of seconds and the callback fires once a minute; without this, an evening beside
+ * somebody would be several hundred rows describing one spot.
+ */
+ @Test
+ public void standingStillDoesNotWriteASecondRowStraightAway() {
+ assertTrue(this.record(LAT, LON, 8, NOON));
+ assertFalse(this.record(LAT, LON, 8, NOON + 60_000));
+
+ assertEquals(1, this.allReports().size());
+ }
+
+ @Test
+ public void movingFarEnoughWritesAnotherRow() {
+ assertTrue(this.record(LAT, LON, 8, NOON));
+ assertTrue(this.record(LAT + 0.0008, LON, 8, NOON + 60_000));
+
+ assertEquals(2, this.allReports().size());
+ }
+
+ @Test
+ public void stayingPutIsWorthRecordingAgainAfterLongEnough() {
+ assertTrue(this.record(LAT, LON, 8, NOON));
+ assertTrue(this.record(LAT, LON, 8, NOON + LocalFixWorthKeeping.AGAIN_AFTER_MS));
+
+ assertEquals(2, this.allReports().size());
+ }
+
+ /**
+ * A network report must not suppress the local row that supersedes it.
+ *
+ *
The two answer different questions: "when did somebody else last see it" and "when did
+ * I last hear it". Deciding the write rule from the newest report of any kind would
+ * mean a tag fetched a minute ago never records the far more precise position of being heard
+ * in the same room.
+ */
+ @Test
+ public void anAppleReportDoesNotStandInForTheLastLocalOne() {
+ this.db.locationReportDao().insertAll(LocationReport.builder()
+ .hashId("an-apple-report")
+ .beaconId(A_TAG)
+ .publishedAt(NOON)
+ .description("Apple")
+ .timestamp(NOON)
+ .confidence(2)
+ .latitude(LAT)
+ .longitude(LON)
+ .horizontalAccuracy(120)
+ .status(0)
+ .lastUpdate(NOON)
+ .provenance(LocationReport.PROVENANCE_APPLE)
+ .build());
+
+ assertTrue("a fresh network report must not suppress a local sighting",
+ this.record(LAT, LON, 8, NOON + 1_000));
+ }
+
+ /**
+ * Two sightings of the same tag at the same moment and place collapse to one row.
+ *
+ *
The id is a hash of what the report says, so a repeat cannot accumulate - which is what
+ * keeps a retry or a duplicated callback from doubling the history.
+ */
+ @Test
+ public void theSameSightingTwiceIsOneRow() {
+ this.repo.recordLocalSighting(A_TAG, LAT, LON, 8, A_STATUS_BYTE, NOON).blockingFirst();
+ this.repo.recordLocalSighting(A_TAG, LAT, LON, 8, A_STATUS_BYTE, NOON).blockingFirst();
+
+ assertEquals(1, this.allReports().size());
+ }
+
+ /** And the map reads it: the newest row per tag is what gets drawn. */
+ @Test
+ public void aLocalReportBecomesTheTagsLatestPosition() {
+ this.record(LAT, LON, 8, NOON);
+
+ final LocationReport latest = this.db.locationReportDao().getLastFor(A_TAG);
+
+ assertNotNull(latest);
+ assertEquals(LocationReport.PROVENANCE_LOCAL, latest.provenance);
+ }
+}
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/LatestReportPerBeaconTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/LatestReportPerBeaconTest.java
index d2b15d42..39b41b36 100644
--- a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/LatestReportPerBeaconTest.java
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/LatestReportPerBeaconTest.java
@@ -176,6 +176,7 @@ private static LocationReport report(
.horizontalAccuracy(10L)
.status(0)
.lastUpdate(timestamp)
+ .provenance(LocationReport.PROVENANCE_APPLE)
.build();
}
}
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java
index 025b0408..7567c668 100644
--- a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java
@@ -457,12 +457,12 @@ public void migrate5To6_handlesEmptyDatabase() throws IOException {
* The path an actual user takes, which is never one version at a time.
*
*
People skip releases, so the upgrade that has to work is v1 straight to the current
- * version - six migrations in a row over rows written by a schema none of them were tested
+ * version - seven migrations in a row over rows written by a schema none of them were tested
* against individually. Everything the user owns has to still be there at the end: their
* beacons, their location history, and the nicknames they set.
*/
@Test
- public void migrate1To7_directUpgradePreservesEverything() throws IOException {
+ public void migrate1To8_directUpgradePreservesEverything() throws IOException {
try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) {
insertImport(db, 1L);
insertOwnedBeaconV1(db, "beacon-a", 1L, BEACON_PLIST, false);
@@ -472,29 +472,30 @@ public void migrate1To7_directUpgradePreservesEverything() throws IOException {
}
SupportSQLiteDatabase db = helper.runMigrationsAndValidate(
- TEST_DB, 7, true,
+ TEST_DB, 8, true,
OpenTagViewerDatabase.MIGRATION_1_2,
OpenTagViewerDatabase.MIGRATION_2_3,
OpenTagViewerDatabase.MIGRATION_3_4,
OpenTagViewerDatabase.MIGRATION_4_5,
OpenTagViewerDatabase.MIGRATION_5_6,
- OpenTagViewerDatabase.MIGRATION_6_7);
+ OpenTagViewerDatabase.MIGRATION_6_7,
+ OpenTagViewerDatabase.MIGRATION_7_8);
try (Cursor cursor = db.query("SELECT COUNT(*) FROM OwnedBeacons")) {
assertTrue(cursor.moveToFirst());
- assertEquals("beacons lost on a direct v1 to v7 upgrade", 2, cursor.getInt(0));
+ assertEquals("beacons lost on a direct v1 to v8 upgrade", 2, cursor.getInt(0));
}
try (Cursor cursor = db.query("SELECT COUNT(*) FROM LocationReport")) {
assertTrue(cursor.moveToFirst());
- assertEquals("location history lost on a direct v1 to v7 upgrade", 1, cursor.getInt(0));
+ assertEquals("location history lost on a direct v1 to v8 upgrade", 1, cursor.getInt(0));
}
try (Cursor cursor = db.query(
"SELECT ui_name, ui_order FROM UserBeaconOptions WHERE beacon_id = ?",
new Object[] {"beacon-a"})) {
- assertTrue("the user's nickname did not survive six migrations", cursor.moveToFirst());
+ assertTrue("the user's nickname did not survive seven migrations", cursor.moveToFirst());
assertEquals("Wallet", cursor.getString(0));
assertTrue("nothing may arrive already arranged", cursor.isNull(1));
}
@@ -584,6 +585,64 @@ public void migrate6To7_handlesEmptyDatabase() throws IOException {
}
}
+ /**
+ * v7 to v8 marks every existing report as Apple's, which is what they all are.
+ *
+ *
Local rows could not exist before the column did, so the default is not a fallback but
+ * the truth. Getting it wrong in the other direction would be worse than it looks: the CSV
+ * export would hand somebody a file claiming their own phone had recorded positions it never
+ * took.
+ */
+ @Test
+ public void migrate7To8_marksExistingReportsAsComingFromApple() throws IOException {
+ try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 5)) {
+ insertImport(db, 1L);
+ insertOwnedBeaconV5(db, BEACON_ID, 1L, BEACON_PLIST, false);
+ insertLocationReport(db, "hash-1", BEACON_ID, 1700000000000L);
+ }
+
+ helper.runMigrationsAndValidate(TEST_DB, 6, true, OpenTagViewerDatabase.MIGRATION_5_6);
+ helper.runMigrationsAndValidate(TEST_DB, 7, true, OpenTagViewerDatabase.MIGRATION_6_7);
+ SupportSQLiteDatabase db = helper.runMigrationsAndValidate(
+ TEST_DB, 8, true, OpenTagViewerDatabase.MIGRATION_7_8);
+
+ try (Cursor cursor = db.query(
+ "SELECT provenance FROM LocationReport WHERE hash_id = ?",
+ new Object[] {"hash-1"})) {
+ assertTrue("the report did not survive v7 to v8", cursor.moveToFirst());
+ assertEquals("apple", cursor.getString(0));
+ }
+ }
+
+ /** And a local row can be written straight after the upgrade. */
+ @Test
+ public void migrate7To8_theColumnAcceptsALocalRow() throws IOException {
+ try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 5)) {
+ insertImport(db, 1L);
+ insertOwnedBeaconV5(db, BEACON_ID, 1L, BEACON_PLIST, false);
+ }
+
+ helper.runMigrationsAndValidate(TEST_DB, 6, true, OpenTagViewerDatabase.MIGRATION_5_6);
+ helper.runMigrationsAndValidate(TEST_DB, 7, true, OpenTagViewerDatabase.MIGRATION_6_7);
+ SupportSQLiteDatabase db = helper.runMigrationsAndValidate(
+ TEST_DB, 8, true, OpenTagViewerDatabase.MIGRATION_7_8);
+
+ db.execSQL("INSERT INTO LocationReport (hash_id, beacon_id, published_at, description,"
+ + " timestamp, confidence, latitude, longitude, horizontal_accuracy,"
+ + " status, last_update, provenance)"
+ + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ new Object[] {"hash-local", BEACON_ID, 1700000000000L, "Heard over Bluetooth",
+ 1700000000000L, 0, 49.4767, 8.5622, 8, 32, 1700000000000L, "local"});
+
+ try (Cursor cursor = db.query(
+ "SELECT provenance, horizontal_accuracy FROM LocationReport WHERE hash_id = ?",
+ new Object[] {"hash-local"})) {
+ assertTrue(cursor.moveToFirst());
+ assertEquals("local", cursor.getString(0));
+ assertEquals(8, cursor.getInt(1));
+ }
+ }
+
/**
* A beacon as v4 and v5 store one.
*
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/history/TheHistoryScreenDrawsTheDayTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/history/TheHistoryScreenDrawsTheDayTest.java
index bd7ebc5e..7c8fe7ae 100644
--- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/history/TheHistoryScreenDrawsTheDayTest.java
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/history/TheHistoryScreenDrawsTheDayTest.java
@@ -556,6 +556,7 @@ private void givenReportsOn(final int daysBack, final double[][] positions,
.horizontalAccuracy(83)
.status(144)
.lastUpdate(at)
+ .provenance(LocationReport.PROVENANCE_APPLE)
.build());
}
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/AMapWithTagsOnIt.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/AMapWithTagsOnIt.java
index 58843ee6..00635f71 100644
--- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/AMapWithTagsOnIt.java
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/AMapWithTagsOnIt.java
@@ -200,6 +200,7 @@ public AMapWithTagsOnIt seed(final String... names) {
.horizontalAccuracy(83)
.status(144)
.lastUpdate(reportedAt)
+ .provenance(LocationReport.PROVENANCE_APPLE)
.build());
}
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TheMapDrawsWhatIsStoredTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TheMapDrawsWhatIsStoredTest.java
index 522db1ba..24262538 100644
--- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TheMapDrawsWhatIsStoredTest.java
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TheMapDrawsWhatIsStoredTest.java
@@ -144,6 +144,7 @@ public void seedTwoTagsAndSubstituteTheMap() {
.horizontalAccuracy(83)
.status(144)
.lastUpdate(1_700_000_000_000L)
+ .provenance(LocationReport.PROVENANCE_APPLE)
.build());
// **Otherwise the startup fetch is skipped.** RefreshPolicy is a process-wide singleton
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/TheDeviceListNoticesNewLocationsTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/TheDeviceListNoticesNewLocationsTest.java
index 6b4e540c..4b279a63 100644
--- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/TheDeviceListNoticesNewLocationsTest.java
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/TheDeviceListNoticesNewLocationsTest.java
@@ -124,6 +124,7 @@ private void givenHistoryWasFetchedWhileTheListWasAway() {
.horizontalAccuracy(83)
.status(144)
.lastUpdate(System.currentTimeMillis())
+ .provenance(LocationReport.PROVENANCE_APPLE)
.build());
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/AccessorySightingPersister.java b/app/src/main/java/dev/wander/android/opentagviewer/AccessorySightingPersister.java
index a208e337..60c0b756 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/AccessorySightingPersister.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/AccessorySightingPersister.java
@@ -6,6 +6,7 @@
import dev.wander.android.opentagviewer.ble.BleSoundTriggerUpdate;
import dev.wander.android.opentagviewer.ble.NearbyTagSighting;
import dev.wander.android.opentagviewer.db.repo.BeaconRepository;
+import dev.wander.android.opentagviewer.util.android.PhoneLocation;
/**
* The one place a Bluetooth sighting is written down, for both screens and both kinds of
@@ -31,8 +32,16 @@ final class AccessorySightingPersister {
private final BeaconRepository beaconRepo;
- AccessorySightingPersister(final BeaconRepository beaconRepo) {
+ /**
+ * Where the phone was when a tag was heard, or null throughout when the caller has no
+ * business recording positions.
+ */
+ private final PhoneLocation phoneLocation;
+
+ AccessorySightingPersister(
+ final BeaconRepository beaconRepo, final PhoneLocation phoneLocation) {
this.beaconRepo = beaconRepo;
+ this.phoneLocation = phoneLocation;
}
/**
@@ -48,6 +57,7 @@ final class AccessorySightingPersister {
void onSighting(final NearbyTagSighting sighting, final String mac) {
this.persist(sighting.getBeaconId(), mac, sighting.getSeenAtMs());
this.persistLastSighting(sighting);
+ this.persistPosition(sighting);
}
/**
@@ -68,6 +78,36 @@ private void persist(final String beaconId, final String mac, final long seenAtM
"Failed to persist a sighting for beaconId=" + beaconId, error));
}
+ /**
+ * Records where the phone was as the tag's position, when there is a fix to record.
+ *
+ *
Hearing the tag puts it within Bluetooth range of here, which is a far tighter claim
+ * than a network report carries - see {@code BeaconRepository#recordLocalSighting}. Not
+ * every sighting earns a row; the repository decides, because sightings arrive far faster
+ * than positions are worth keeping.
+ *
+ *
No fix means no row, silently. Location may be off, the permission may have been
+ * declined, or the phone may not have one yet, and none of those is a failure of the
+ * sighting.
+ */
+ private void persistPosition(final NearbyTagSighting sighting) {
+ final PhoneLocation.Fix fix = this.phoneLocation.lastKnown();
+ if (fix == null) {
+ return;
+ }
+
+ this.beaconRepo.recordLocalSighting(
+ sighting.getBeaconId(),
+ fix.getLatitude(),
+ fix.getLongitude(),
+ fix.getAccuracyMetres(),
+ sighting.getStatusByte(),
+ sighting.getSeenAtMs())
+ .subscribe(written -> { }, error -> Log.w(TAG,
+ "Failed to persist a position for beaconId=" + sighting.getBeaconId(),
+ error));
+ }
+
private void persistLastSighting(final NearbyTagSighting sighting) {
this.beaconRepo.storeLastSighting(
sighting.getBeaconId(),
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index 70d0199d..f1c30eca 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -77,6 +77,7 @@
import dev.wander.android.opentagviewer.db.room.entity.Import;
import dev.wander.android.opentagviewer.db.room.entity.UserBeaconOptions;
import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil;
+import dev.wander.android.opentagviewer.util.android.FusedPhoneLocation;
import dev.wander.android.opentagviewer.util.android.PropertiesUtil;
import dev.wander.android.opentagviewer.util.android.WebLink;
import dev.wander.android.opentagviewer.util.parse.BatteryLevelDescription;
@@ -218,7 +219,8 @@ protected void onCreate(Bundle savedInstanceState) {
this.beaconRepo = new BeaconRepository(
OpenTagViewerDatabase.getInstance(getApplicationContext()));
- this.sightingPersister = new AccessorySightingPersister(this.beaconRepo);
+ this.sightingPersister = new AccessorySightingPersister(
+ this.beaconRepo, new FusedPhoneLocation(this.getApplicationContext()));
this.beaconData = this.beaconRepo.getById(this.beaconId).blockingFirst();
this.beaconInformation = BeaconDataParser.parse(List.of(this.beaconData)).get(0);
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index 195a8aa8..2af846dd 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -113,6 +113,7 @@
import dev.wander.android.opentagviewer.db.repo.UserDataRepository;
import dev.wander.android.opentagviewer.ui.maps.TagCardHelper;
import dev.wander.android.opentagviewer.ui.maps.TagListSwiperHelper;
+import dev.wander.android.opentagviewer.util.android.FusedPhoneLocation;
import dev.wander.android.opentagviewer.util.LogCollectorUtil;
import dev.wander.android.opentagviewer.util.MapUtils;
import dev.wander.android.opentagviewer.util.TagOrder;
@@ -496,7 +497,8 @@ protected void onCreate(Bundle savedInstanceState) {
this.beaconRepo = new BeaconRepository(
OpenTagViewerDatabase.getInstance(getApplicationContext()));
- this.sightingPersister = new AccessorySightingPersister(this.beaconRepo);
+ this.sightingPersister = new AccessorySightingPersister(
+ this.beaconRepo, new FusedPhoneLocation(this.getApplicationContext()));
this.fusedLocationClient = LocationServices.getFusedLocationProviderClient(this);
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
index 01a8a509..875f0a4c 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
@@ -35,6 +35,7 @@
import dev.wander.android.opentagviewer.util.parse.NamingRecordEditor;
import dev.wander.android.opentagviewer.python.PlistToAccessoryJsonConverter;
import dev.wander.android.opentagviewer.util.BeaconLocationReportHasher;
+import dev.wander.android.opentagviewer.util.LocalFixWorthKeeping;
import dev.wander.android.opentagviewer.util.parse.KeyAlignmentPlist;
import dev.wander.android.opentagviewer.util.rx.ScanOrder;
import dev.wander.android.opentagviewer.util.rx.WideScanBackoff;
@@ -605,6 +606,101 @@ public Completable storeLastSighting(
}).subscribeOn(Schedulers.io());
}
+ /**
+ * Write down where this phone was when it heard the tag, as a location report of its own.
+ *
+ *
The same table as Apple's reports, on purpose. The map marker, the "last
+ * updated" line, the navigate button, the history list and the CSV export all read from
+ * there; a separate table would mean teaching every one of them about a second source. The
+ * {@code provenance} column is what keeps the two distinguishable - see
+ * {@link LocationReport#provenance}.
+ *
+ *
It is the phone's position, not the tag's. Hearing a Find My advertisement puts
+ * the tag within roughly ten metres, which is why {@code horizontal_accuracy} is filled from
+ * the fix's own accuracy rather than invented: for a reader, and for anything that compares
+ * two reports, that is the honest width of the claim. It is also usually an order of
+ * magnitude better than a network report, which describes where a stranger's iPhone thought
+ * it was.
+ *
+ *
Not every sighting earns a row. {@link LocalFixWorthKeeping} decides, because
+ * sightings arrive far faster than positions are worth keeping - a tag beside somebody all
+ * evening would otherwise write hundreds of rows describing one spot, each reverse-geocoded
+ * when shown.
+ *
+ *
Failure is swallowed like every other write on the sighting path: this runs behind a
+ * passive scan nobody asked for, and nothing the user did may fail because of it.
+ *
+ * @return true when a row was written, so a caller can log or test the decision.
+ */
+ public Observable recordLocalSighting(
+ final String beaconId,
+ final double latitude,
+ final double longitude,
+ final long accuracyMetres,
+ final long statusByte,
+ final long heardAtUnixMs) {
+
+ return Observable.fromCallable(() -> {
+ final var dao = db.locationReportDao();
+ final LocationReport last = dao.getLastLocalFor(beaconId);
+
+ final boolean keep = LocalFixWorthKeeping.worthKeeping(
+ last == null ? null : last.latitude,
+ last == null ? null : last.longitude,
+ last == null ? null : last.timestamp,
+ latitude, longitude, heardAtUnixMs);
+
+ if (!keep) {
+ return false;
+ }
+
+ // Built as the shared model first so the id comes out of the same hasher the network
+ // path uses. It folds in the beacon, the timestamp, the coordinates, the status and
+ // the description, so two sightings of the same tag at the same moment and place
+ // collapse to one row instead of accumulating - and a local row can never collide
+ // with an Apple one, because no Apple report carries this description.
+ final BeaconLocationReport report = BeaconLocationReport.builder()
+ .publishedAt(heardAtUnixMs)
+ .description(LOCAL_REPORT_DESCRIPTION)
+ .timestamp(heardAtUnixMs)
+ // Apple's confidence byte is a number this app deliberately does not
+ // interpret - see LocationReportFields. There is nothing honest to put here,
+ // so it stays zero rather than borrowing a scale that means something else.
+ .confidence(0)
+ .latitude(latitude)
+ .longitude(longitude)
+ .horizontalAccuracy(accuracyMetres)
+ .status(statusByte)
+ .build();
+
+ dao.insertAll(LocationReport.builder()
+ .hashId(BeaconLocationReportHasher.getSha256HashFor(beaconId, report))
+ .beaconId(beaconId)
+ .publishedAt(report.getPublishedAt())
+ .description(report.getDescription())
+ .timestamp(report.getTimestamp())
+ .confidence(report.getConfidence())
+ .latitude(report.getLatitude())
+ .longitude(report.getLongitude())
+ .horizontalAccuracy(report.getHorizontalAccuracy())
+ .status(report.getStatus())
+ .lastUpdate(System.currentTimeMillis())
+ .provenance(LocationReport.PROVENANCE_LOCAL)
+ .build());
+
+ Log.d(TAG, "Wrote a local position for beaconId=" + beaconId);
+ return true;
+ }).subscribeOn(Schedulers.io());
+ }
+
+ /**
+ * What a locally heard report says in its description field.
+ *
+ * Apple fills this with its own text, so a fixed string here both labels the row in the
+ * debug panel and guarantees the hash of a local row can never match an Apple one.
+ */
+ public static final String LOCAL_REPORT_DESCRIPTION = "Heard over Bluetooth";
+
/**
* The last thing heard from this tag over Bluetooth, or empty if it never has been.
*
@@ -747,6 +843,10 @@ public Observable