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>> storeToLocationCache( .horizontalAccuracy(locationReport.getHorizontalAccuracy()) .status(locationReport.getStatus()) .lastUpdate(now) + // Everything arriving here was decrypted from Apple's + // network. The local path writes its own rows and sets this + // itself - see recordLocalSighting. + .provenance(LocationReport.PROVENANCE_APPLE) .build() )) .toArray(LocationReport[]::new); 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 6fd60d43..7387a80b 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 @@ -34,7 +34,7 @@ UserBeaconOptions.class, LastBleSighting.class }, - version = 7 + version = 8 ) public abstract class OpenTagViewerDatabase extends RoomDatabase { private static OpenTagViewerDatabase INSTANCE = null; @@ -161,6 +161,25 @@ public void migrate(@NonNull SupportSQLiteDatabase db) { } }; + /** + * v7 → v8: adds {@code provenance} to {@code LocationReport}, saying whether a row came from + * Apple's network or from this phone hearing the tag itself. + * + *

Both kinds live in this table on purpose - everything that draws a tag reads from here - + * but they are not the same claim, and the history export hands somebody a file in which + * they would otherwise be indistinguishable. See {@link LocationReport#provenance}. + * + *

Additive, with a default of {@code apple}. That is not a fallback but the truth for + * every existing row: local rows could not exist before this column did. + */ + public static final Migration MIGRATION_7_8 = new Migration(7, 8) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE LocationReport" + + " ADD COLUMN provenance TEXT NOT NULL DEFAULT 'apple'"); + } + }; + /** * The database file's name, which is also read directly - see * {@code OpenAirTagApplication.isFirstRun()}, which uses the file's presence to tell a new @@ -177,7 +196,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_6_7) + MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8) .build(); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/LocationReportDao.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/LocationReportDao.java index b88f20c2..c462b1d5 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/LocationReportDao.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/LocationReportDao.java @@ -53,6 +53,18 @@ public interface LocationReportDao { @Query("SELECT MAX(timestamp) FROM LocationReport WHERE beacon_id = :beaconId") Long newestReportTimeFor(String beaconId); + /** + * The newest report this phone wrote for one tag, or null if it has never heard it. + * + *

Scoped to local rows because it decides whether the next sighting is worth keeping - + * see {@code LocalFixWorthKeeping}. An Apple report says nothing about that: it describes + * where somebody else's iPhone was, so a fresh one would silently suppress the local row + * that is the more precise of the two. + */ + @Query("SELECT * FROM LocationReport WHERE beacon_id = :beaconId AND provenance = 'local'" + + " ORDER BY timestamp DESC LIMIT 1") + LocationReport getLastLocalFor(String beaconId); + @Insert(onConflict = OnConflictStrategy.REPLACE) void insertAll(LocationReport... locationReports); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LocationReport.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LocationReport.java index ab26741f..0596fa16 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LocationReport.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LocationReport.java @@ -69,4 +69,34 @@ public class LocationReport { @ColumnInfo(name = "last_update") public long lastUpdate; + + /** + * Where this report came from: {@code apple} or {@code local}. + * + *

An Apple report and a locally heard sighting are the same shape and not the same + * claim. An Apple row says some stranger's iPhone overheard the tag and reported a + * position it worked out for itself, typically to within a hundred metres or worse. A local + * row says this phone heard the tag directly, which puts it inside Bluetooth range - tens of + * metres - and records the phone's own position as the tag's. + * + *

Both belong in this table, because everything that draws a tag reads from here: the map + * marker, the "last updated" line, the navigate button and the history. A separate table + * would mean teaching all of them about a second source. + * + *

The column exists because the history is exported. Without it the CSV hands + * somebody a file where their own phone's positions sit unlabelled among Apple's, and + * nothing in the file says which is which. + * + *

Defaults to {@code apple}, which is correct for every row written before this existed: + * they all came from the network. + */ + @NonNull + @ColumnInfo(name = "provenance", defaultValue = PROVENANCE_APPLE) + public String provenance; + + /** Decrypted from Apple's Find My network. */ + public static final String PROVENANCE_APPLE = "apple"; + + /** Heard by this phone's own radio, positioned from this phone's own location. */ + public static final String PROVENANCE_LOCAL = "local"; } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/LocalFixWorthKeeping.java b/app/src/main/java/dev/wander/android/opentagviewer/util/LocalFixWorthKeeping.java new file mode 100644 index 00000000..817a40bb --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/LocalFixWorthKeeping.java @@ -0,0 +1,99 @@ +package dev.wander.android.opentagviewer.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Whether a position heard over Bluetooth is worth writing down, given the last one that was. + * + *

The sighting rate and the useful position rate are two different things. A tag in + * range is heard every second or two, and the sighting callback is already throttled to once a + * minute per tag - right for a battery reading, which costs one row that is overwritten. A + * position costs a row that is kept, and is reverse-geocoded when it is shown. A tag + * sitting beside somebody all evening would write several hundred rows describing the same + * spot, and the history it exists to build would become unreadable in the process. + * + *

So a position is kept when it says something the last one did not: the phone has moved far + * enough that this is a different place, or enough time has passed that "still here" is itself + * worth recording. + * + *

No Android in here - {@code Location.distanceBetween} would drag the whole rule onto a + * device - so both the distance and the rule are covered by a JVM test. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class LocalFixWorthKeeping { + + /** + * How far the phone must have moved before the same tag earns another row. + * + *

Bluetooth range is the yardstick, not GPS precision. A match means the tag was within + * roughly ten metres of the phone, so two fixes closer together than this describe the same + * place as far as anybody looking for the tag is concerned. Below it the rows would differ + * only by GPS noise, which is itself several metres when standing still. + */ + public static final double MOVED_METRES = 25.0; + + /** + * How long the same place stays worth re-recording. + * + *

Not zero, because "the keys were still here an hour later" is information a history + * should carry - it is the difference between a tag last seen at home this morning and one + * that has been there all day. Long enough that a stationary tag writes a couple of dozen + * rows a day rather than a thousand. + */ + public static final long AGAIN_AFTER_MS = 15 * 60 * 1000L; + + /** + * Mean Earth radius in metres, for {@link #metresBetween}. + * + *

A sphere, not the ellipsoid the map projects on. Over the distances this rule cares + * about - tens of metres - the two disagree by centimetres, and the threshold above is a + * judgement call to within metres anyway. + */ + private static final double EARTH_RADIUS_M = 6_371_000.0; + + /** + * True when this fix should be written as a new report for the tag. + * + * @param lastMs when the last local report for this tag was written, or null if there + * is none - the first sighting of a tag is always worth keeping. + */ + public static boolean worthKeeping( + final Double lastLatitude, + final Double lastLongitude, + final Long lastMs, + final double latitude, + final double longitude, + final long nowMs) { + + if (lastMs == null || lastLatitude == null || lastLongitude == null) { + return true; + } + + if (nowMs - lastMs >= AGAIN_AFTER_MS) { + return true; + } + + return metresBetween(lastLatitude, lastLongitude, latitude, longitude) >= MOVED_METRES; + } + + /** + * Great-circle distance in metres between two coordinates, by the haversine formula. + * + *

Chosen over the flat-earth approximation because the latter needs a cosine correction + * that is easy to leave out, and gets worse the further from the equator the user happens to + * live - a bug nobody in the wrong hemisphere would ever report. + */ + public static double metresBetween( + final double lat1, final double lon1, final double lat2, final double lon2) { + + final double dLat = Math.toRadians(lat2 - lat1); + final double dLon = Math.toRadians(lon2 - lon1); + + final double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) + * Math.sin(dLon / 2) * Math.sin(dLon / 2); + + return EARTH_RADIUS_M * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/android/FusedPhoneLocation.java b/app/src/main/java/dev/wander/android/opentagviewer/util/android/FusedPhoneLocation.java new file mode 100644 index 00000000..7feedb1a --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/android/FusedPhoneLocation.java @@ -0,0 +1,94 @@ +package dev.wander.android.opentagviewer.util.android; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.pm.PackageManager; +import android.location.Location; +import android.util.Log; + +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import com.google.android.gms.location.FusedLocationProviderClient; +import com.google.android.gms.location.LocationServices; +import com.google.android.gms.tasks.Tasks; + +import java.util.concurrent.TimeUnit; + +/** + * The real {@link PhoneLocation}: the cached fix Play services already holds. + * + *

Same client the map uses for its own blue dot, so this asks for nothing the app was not + * already granted and starts no new location request. + */ +public class FusedPhoneLocation implements PhoneLocation { + private static final String TAG = FusedPhoneLocation.class.getSimpleName(); + + /** + * How long to wait for a cached fix before giving up. + * + *

Short on purpose. {@code getLastLocation} answers from memory when there is anything to + * answer with, so a wait longer than this means something is wrong rather than slow - and + * this blocks a thread on the sighting path while it waits. + */ + private static final long WAIT_MS = 2_000L; + + private final Context context; + private final FusedLocationProviderClient client; + + public FusedPhoneLocation(final Context context) { + this.context = context.getApplicationContext(); + this.client = LocationServices.getFusedLocationProviderClient(this.context); + } + + @Nullable + @Override + @SuppressLint("MissingPermission") + public Fix lastKnown() { + if (!this.locationGranted()) { + // Not an error: the map asks for this permission, and somebody who declined it has + // said they do not want their position recorded. The sighting still records what it + // heard, minus the position. + return null; + } + + try { + final Location location = + Tasks.await(this.client.getLastLocation(), WAIT_MS, TimeUnit.MILLISECONDS); + + if (location == null) { + return null; + } + + // hasAccuracy() is false on a fix from a provider that does not report one. Zero + // would then be written as "accurate to the metre", which is a stronger claim than + // anything here can make - so it becomes the width of Bluetooth range instead, which + // is what hearing the tag actually established. + final long accuracy = location.hasAccuracy() + ? Math.round(location.getAccuracy()) + : BLUETOOTH_RANGE_M; + + return new Fix(location.getLatitude(), location.getLongitude(), accuracy); + } catch (final Exception e) { + Log.d(TAG, "No cached location available for this sighting", e); + return null; + } + } + + /** + * The accuracy claimed when the system reports none of its own. + * + *

Hearing a Find My advertisement is itself a distance measurement of sorts: the tag was + * in Bluetooth range. That is the weakest true statement available, so it is the right + * fallback - see {@code NearbyTagLabel} on why RSSI cannot narrow it further. + */ + private static final long BLUETOOTH_RANGE_M = 10L; + + private boolean locationGranted() { + return ContextCompat.checkSelfPermission(this.context, Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED + || ContextCompat.checkSelfPermission(this.context, Manifest.permission.ACCESS_COARSE_LOCATION) + == PackageManager.PERMISSION_GRANTED; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/android/PhoneLocation.java b/app/src/main/java/dev/wander/android/opentagviewer/util/android/PhoneLocation.java new file mode 100644 index 00000000..d6bdf5da --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/android/PhoneLocation.java @@ -0,0 +1,49 @@ +package dev.wander.android.opentagviewer.util.android; + +import androidx.annotation.Nullable; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Where this phone is, for the moment a tag is heard over Bluetooth. + * + *

A seam, because the real answer needs Google Play services and a granted permission. + * The rule that uses it - is this position worth writing down, and what accuracy does it claim - + * is ordinary logic that should not need a device to exercise. + * + *

Null is an ordinary answer, not a failure: location may be off, the permission may not have + * been granted, or the phone may simply have no fix yet. A sighting then records what it always + * did (battery, alignment) and no position, which is the honest outcome. + */ +public interface PhoneLocation { + + /** + * The last position the system already has, or null. + * + *

Deliberately the cached fix rather than a fresh one. Asking for a new fix per + * sighting would be the most expensive thing on a passive scan path, and the accuracy it + * buys is far below what the claim needs: hearing the tag at all already places it within + * Bluetooth range, so a fix good to a few metres is not the limiting factor. + * + *

Blocking. Called from the sighting path, which runs on an Rx io thread. + */ + @Nullable + Fix lastKnown(); + + /** A position with the accuracy the system claims for it, in metres. */ + @AllArgsConstructor + @Getter + final class Fix { + private final double latitude; + private final double longitude; + + /** + * Radius in metres the system claims for this position. + * + *

Written straight into a report's {@code horizontal_accuracy}, which is the same + * field Apple's reports carry, so the two are comparable on the same scale. + */ + private final long accuracyMetres; + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/LocalFixWorthKeepingTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/LocalFixWorthKeepingTest.java new file mode 100644 index 00000000..ba6f35fa --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/LocalFixWorthKeepingTest.java @@ -0,0 +1,88 @@ +package dev.wander.android.opentagviewer.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * The rule that keeps a locally heard position from flooding the history. + * + *

Sightings arrive far faster than positions are worth keeping: a tag in range is heard every + * second or two, and even the throttled callback fires once a minute. Writing a row each time + * would put several hundred identical points into a tag's day, each one reverse-geocoded when + * shown. + */ +public class LocalFixWorthKeepingTest { + + /** Ilvesheim, where the measurements behind this feature were taken. */ + private static final double LAT = 49.4767; + private static final double LON = 8.5622; + + private static final long NOON = 1_700_000_000_000L; + + @Test + public void theFirstFixForATagIsAlwaysKept() { + assertTrue(LocalFixWorthKeeping.worthKeeping(null, null, null, LAT, LON, NOON)); + } + + @Test + public void standingStillDoesNotWriteAgainStraightAway() { + assertFalse("a tag beside somebody must not write a row per sighting", + LocalFixWorthKeeping.worthKeeping(LAT, LON, NOON, LAT, LON, NOON + 60_000)); + } + + @Test + public void standingStillIsWorthRecordingAgainEventually() { + assertTrue("still here an hour later is information a history should carry", + LocalFixWorthKeeping.worthKeeping( + LAT, LON, NOON, LAT, LON, NOON + LocalFixWorthKeeping.AGAIN_AFTER_MS)); + } + + /** + * Roughly 90 metres north, which is past the threshold: a different place. + */ + @Test + public void movingFarEnoughWritesAgainImmediately() { + assertTrue(LocalFixWorthKeeping.worthKeeping( + LAT, LON, NOON, LAT + 0.0008, LON, NOON + 1_000)); + } + + /** + * Roughly 5 metres, which is inside GPS noise standing still - two rows here would differ + * only by the fix wobbling, not by anything having happened. + */ + @Test + public void aFixThatOnlyWobbledIsNotADifferentPlace() { + assertFalse(LocalFixWorthKeeping.worthKeeping( + LAT, LON, NOON, LAT + 0.000045, LON, NOON + 1_000)); + } + + @Test + public void distanceIsMeasuredOnTheGlobeRatherThanTheGrid() { + // A hundredth of a degree of latitude is about 1.11 km anywhere on Earth. + final double metres = LocalFixWorthKeeping.metresBetween(LAT, LON, LAT + 0.01, LON); + + assertEquals(1110.0, metres, 10.0); + } + + /** + * A degree of longitude shrinks toward the poles. A flat approximation without the cosine + * correction gets this wrong by more the further north the user lives, which is a bug that + * would never be reported by anybody near the equator. + */ + @Test + public void aDegreeOfLongitudeIsShorterThisFarNorth() { + final double eastWest = LocalFixWorthKeeping.metresBetween(LAT, LON, LAT, LON + 0.01); + final double northSouth = LocalFixWorthKeeping.metresBetween(LAT, LON, LAT + 0.01, LON); + + assertTrue("east-west (" + eastWest + "m) must be shorter than north-south (" + + northSouth + "m) at 49 degrees north", eastWest < northSouth * 0.7); + } + + @Test + public void theSamePointIsZeroMetresApart() { + assertEquals(0.0, LocalFixWorthKeeping.metresBetween(LAT, LON, LAT, LON), 0.0001); + } +} From 3dfa9830f0ff6a9d44658dc2a604607ef92499ba Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:38:29 +0200 Subject: [PATCH 37/61] Check one key index instead of re-deriving the window The 48-hour candidate margin made the alignment correction expensive, and the correction runs on the sighting cadence: once a minute per tag. With two tags in range the app sat at 135% CPU continuously and Android eventually killed MapsActivity with an ANR for not answering input. Measured on desktop CPython, several times slower under Chaquopy: margin 12h recordAccessorySeen 0.29s currentMacAddresses 0.30s margin 48h recordAccessorySeen 1.15s currentMacAddresses 1.13s Widening the margin was right - it is derived from how far a secondary key can leave alignment behind - but the same constant feeds the correction, and that was not accounted for. Java already knew the answer and threw it away. currentMacAddresses returns each address with the index it was derived at; NearbyTagIndex kept only the beacon. It now carries both, the sighting carries the index, and it reaches recordAccessorySeen as a hint. **A hint, not an answer, and the distinction is the whole safety argument.** Only Python can tell a primary key from a secondary one, and that is what decides whether an index may be trusted at all. So it still re-derives the keys itself and reads the type there; the hint only says where to look first. A hint that misses falls back to the wide search, because the candidate set may have rolled since the scan that matched, and trusting the hint to be exhaustive would silently drop a correction that was there to be made. Checking one index is three key derivations against about 1150. Measured at 0.02s against 1.02s, with both paths returning the same answer, which is asserted rather than assumed. The correction is also throttled to once every fifteen minutes per tag, which is the rate the keys actually rotate. It costs nothing now, but correcting faster than the keys move re-derives the same answer and writes nothing. Battery and position stay on the faster cadence: they cost a row each. The ring path deliberately passes no hint. BleSoundTriggerResult carries the address only, on purpose, and it runs once per button press rather than on a scan cadence. --- .../AccessorySightingPersister.java | 45 +++++++++++++++-- .../opentagviewer/ble/NearbyTagIndex.java | 50 +++++++++++++++---- .../opentagviewer/ble/NearbyTagSighting.java | 11 ++++ .../opentagviewer/ble/NearbyTagWatcher.java | 9 ++-- .../opentagviewer/ble/NearbyTagIndexTest.java | 43 ++++++++++++---- .../ble/NearbyTagSightingsTest.java | 6 +-- .../ble/NearbyTagWatcherTest.java | 2 +- 7 files changed, 132 insertions(+), 34 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 60c0b756..2fa035e0 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/AccessorySightingPersister.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/AccessorySightingPersister.java @@ -2,6 +2,10 @@ import android.util.Log; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + import dev.wander.android.opentagviewer.ble.BleSoundTriggerPhase; import dev.wander.android.opentagviewer.ble.BleSoundTriggerUpdate; import dev.wander.android.opentagviewer.ble.NearbyTagSighting; @@ -55,11 +59,40 @@ final class AccessorySightingPersister { * {@code BeaconRepository#storeLastSighting}. */ void onSighting(final NearbyTagSighting sighting, final String mac) { - this.persist(sighting.getBeaconId(), mac, sighting.getSeenAtMs()); + this.maybeCorrectAlignment(sighting, mac); this.persistLastSighting(sighting); this.persistPosition(sighting); } + /** + * How often one tag's alignment is worth re-deriving. + * + *

The keys only move every fifteen minutes, so correcting faster than that buys + * nothing - the second call within one rotation re-derives the same answer and writes + * nothing. It is also the most expensive thing on this path by a wide margin: the candidate + * window spans 48 hours, which is around 1150 key derivations, measured at 1.15s on desktop + * and several times that under Chaquopy. + * + *

Running it on the sighting callback's own once-a-minute cadence put the app at 135% CPU + * with two tags in range, continuously, and Android eventually killed it for not answering + * input. Battery and position stay on the faster cadence: they cost a row each. + */ + private static final long ALIGNMENT_INTERVAL_MS = TimeUnit.MINUTES.toMillis(15); + + /** When each tag's alignment was last re-derived. Written from the Rx io scheduler. */ + private final Map lastAlignmentMs = new ConcurrentHashMap<>(); + + private void maybeCorrectAlignment(final NearbyTagSighting sighting, final String mac) { + final Long last = this.lastAlignmentMs.get(sighting.getBeaconId()); + if (last != null && sighting.getSeenAtMs() - last < ALIGNMENT_INTERVAL_MS) { + return; + } + this.lastAlignmentMs.put(sighting.getBeaconId(), sighting.getSeenAtMs()); + + this.persist(sighting.getBeaconId(), mac, sighting.getSeenAtMs(), + sighting.getKeyIndex()); + } + /** * 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. @@ -69,11 +102,15 @@ void keepWhatTheSightingProved(final String beaconId, final BleSoundTriggerUpdat || update.getResult().getMatchedMac() == null) { return; } - this.persist(beaconId, update.getResult().getMatchedMac(), System.currentTimeMillis()); + // No hint from the ring path: BleSoundTriggerResult carries the address only, on + // purpose, and this runs once per button press rather than on a scan cadence. + this.persist(beaconId, update.getResult().getMatchedMac(), System.currentTimeMillis(), + null); } - private void persist(final String beaconId, final String mac, final long seenAtMs) { - this.beaconRepo.recordAccessorySighting(beaconId, mac, seenAtMs) + private void persist(final String beaconId, final String mac, final long seenAtMs, + final Integer hintIndex) { + this.beaconRepo.recordAccessorySighting(beaconId, mac, seenAtMs, hintIndex) .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/ble/NearbyTagIndex.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagIndex.java index d2cb8de3..98dd94df 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 @@ -30,7 +30,7 @@ * 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 + * (it is blocking Python), while {@link #matchFor} 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, @@ -49,7 +49,34 @@ public final class NearbyTagIndex { */ static final long MAX_AGE_MS = TimeUnit.MINUTES.toMillis(10); - private volatile Map beaconIdByMac = Map.of(); + private volatile Map matchByMac = Map.of(); + + /** + * Which tag an address belongs to, and the index its key was derived at. + * + *

The index is carried, not acted on. Only Python can tell a primary key from a + * secondary one, and that is what decides whether an index may be trusted - see + * {@code AccessoryMacResolver#recordSeen}. Passing it on as a hint lets the correction check + * one index instead of re-deriving a 48-hour window, which is the difference between three + * key derivations and around 1150. + */ + public static final class Match { + private final String beaconId; + private final int keyIndex; + + Match(final String beaconId, final int keyIndex) { + this.beaconId = beaconId; + this.keyIndex = keyIndex; + } + + public String getBeaconId() { + return this.beaconId; + } + + public int getKeyIndex() { + return this.keyIndex; + } + } private volatile long builtAtMs = Long.MIN_VALUE; /** True when this has never been built, or was built long enough ago to be doubted. */ @@ -72,7 +99,7 @@ public void rebuild( final Map accessoryJsonByBeaconId, final AccessoryMacResolver resolver, final long nowMs) { - final Map rebuilt = new HashMap<>(); + final Map rebuilt = new HashMap<>(); for (final Map.Entry entry : accessoryJsonByBeaconId.entrySet()) { // Only the address is wanted here; the key index each maps to is not this class's @@ -94,32 +121,33 @@ public void rebuild( continue; } - for (final String mac : candidates.keySet()) { - if (mac != null) { + for (final Map.Entry candidate : candidates.entrySet()) { + if (candidate.getKey() != null && candidate.getValue() != 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()); + rebuilt.put(candidate.getKey().toUpperCase(Locale.ROOT), + new Match(entry.getKey(), candidate.getValue())); } } } // Swapped whole, not mutated in place - see the class doc on the reader thread. - this.beaconIdByMac = rebuilt; + this.matchByMac = rebuilt; this.builtAtMs = nowMs; } - /** The beacon this address belongs to, or null if it is not one of ours. */ + /** The tag this address belongs to and the index it came from, or null if it is not ours. */ @Nullable - public String beaconIdFor(@Nullable final String scannedAddress) { + public Match matchFor(@Nullable final String scannedAddress) { if (scannedAddress == null) { return null; } - return this.beaconIdByMac.get(scannedAddress.toUpperCase(Locale.ROOT)); + return this.matchByMac.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(); + return this.matchByMac.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 index 98129425..d3de828e 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 @@ -25,6 +25,17 @@ public final class NearbyTagSighting { private final String beaconId; + /** + * The key index the matched address was derived at, as a hint for the alignment correction. + * + *

Carried, never acted on here. Only Python can tell whether that index came from + * a primary or a secondary key, and that is what decides whether it may be trusted - see + * {@code AccessoryMacResolver#recordSeen}. Passing it along lets the correction verify one + * index instead of re-deriving a 48-hour window: three key derivations instead of about + * 1150. + */ + private final int keyIndex; + /** Signal strength in dBm. Negative; closer to zero is nearer. */ private final int rssi; 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 bb0f16df..f808845d 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 @@ -311,13 +311,14 @@ NearbyTagSighting sightingFrom(final ScanResult result) { } // 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) { + final NearbyTagIndex.Match match = this.index.matchFor(result.getDevice().getAddress()); + if (match == null) { return null; } - return new NearbyTagSighting(beaconId, result.getRssi(), advertisement.getBatteryLevel(), - advertisement.getStatusByte(), advertisement.getState(), this.clock.nowMs()); + return new NearbyTagSighting(match.getBeaconId(), match.getKeyIndex(), result.getRssi(), + advertisement.getBatteryLevel(), advertisement.getStatusByte(), + advertisement.getState(), this.clock.nowMs()); } /** 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 2b7b0af5..f1d74ad7 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 @@ -53,9 +53,9 @@ public void mapsEveryCandidateAddressBackToItsTag() { 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")); + assertEquals(KEYS, index.matchFor("AA:AA:AA:AA:AA:01").getBeaconId()); + assertEquals(KEYS, index.matchFor("AA:AA:AA:AA:AA:02").getBeaconId()); + assertEquals(BIKE, index.matchFor("BB:BB:BB:BB:BB:01").getBeaconId()); } @Test @@ -63,8 +63,8 @@ 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)); + assertNull(index.matchFor("CC:CC:CC:CC:CC:CC")); + assertNull(index.matchFor(null)); } /** Neither side promises a casing forever, and a casing mismatch would present as @@ -74,8 +74,8 @@ public void matchingIgnoresCase() { final NearbyTagIndex index = new NearbyTagIndex(); 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")); + assertEquals(KEYS, index.matchFor("AA:BB:CC:DD:EE:FF").getBeaconId()); + assertEquals(KEYS, index.matchFor("aa:bb:cc:dd:ee:ff").getBeaconId()); } // --- expiry ------------------------------------------------------------------------------- @@ -111,8 +111,8 @@ public void rebuildingReplacesTheOldAddressesRatherThanAccumulating() { 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")); - assertEquals(KEYS, index.beaconIdFor("AA:AA:AA:AA:AA:99")); + assertNull("a rolled-past address must stop matching", index.matchFor("AA:AA:AA:AA:AA:01")); + assertEquals(KEYS, index.matchFor("AA:AA:AA:AA:AA:99").getBeaconId()); } /** @@ -128,7 +128,7 @@ public void oneUnresolvableTagDoesNotCostTheOthers() { final NearbyTagIndex index = new NearbyTagIndex(); 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(KEYS, index.matchFor("AA:AA:AA:AA:AA:01").getBeaconId()); assertEquals(1, index.size()); } @@ -153,7 +153,7 @@ public void aTagTheResolverRefusesDoesNotCostTheOthers() { final NearbyTagIndex index = new NearbyTagIndex(); index.rebuild(tags, refusesOne, 0L); - assertEquals(KEYS, index.beaconIdFor("AA:AA:AA:AA:AA:01")); + assertEquals(KEYS, index.matchFor("AA:AA:AA:AA:AA:01").getBeaconId()); assertEquals(1, index.size()); } @@ -169,6 +169,27 @@ public void aResolverThatRefusesEverythingLeavesAnEmptyIndexRatherThanThrowing() index.isStale(5_000L)); } + /** + * The index the address was derived at is carried, not discarded. + * + *

It is the hint that lets the alignment correction check one index instead of + * re-deriving a 48-hour window - three key derivations against about 1150. Dropping it here + * is what made that correction expensive enough to get the app killed for not answering + * input, and nothing would have failed to say so. + */ + @Test + public void eachAddressRemembersTheIndexItWasDerivedAt() { + final Map byMac = new HashMap<>(); + byMac.put("AA:AA:AA:AA:AA:01", 6221); + byMac.put("AA:AA:AA:AA:AA:02", 6222); + + final NearbyTagIndex index = new NearbyTagIndex(); + index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of("j", byMac)), 0L); + + assertEquals(6221, index.matchFor("AA:AA:AA:AA:AA:01").getKeyIndex()); + assertEquals(6222, index.matchFor("AA:AA:AA:AA:AA:02").getKeyIndex()); + } + @Test public void resolvesEachTagExactlyOncePerRebuild() { final AtomicInteger calls = new AtomicInteger(); 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 a4b4f5fb..2ed06007 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, 0x00, State.SEPARATED, atMs); + return new NearbyTagSighting(beaconId, 4321, -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, 0x00, State.SEPARATED, 0L)); - sightings.record(new NearbyTagSighting(KEYS, -40, BatteryLevel.LOW, 0x80, State.SEPARATED, 100L)); + sightings.record(new NearbyTagSighting(KEYS, 4321, -90, BatteryLevel.FULL, 0x00, State.SEPARATED, 0L)); + sightings.record(new NearbyTagSighting(KEYS, 4321, -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 013ce64c..aea19427 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 @@ -42,7 +42,7 @@ private static NearbyTagSighting sightingOf( final String beaconId, final FindMyAdvertisement.BatteryLevel level, final int statusByte) { - return new NearbyTagSighting(beaconId, -60, level, statusByte, + return new NearbyTagSighting(beaconId, 4321, -60, level, statusByte, FindMyAdvertisement.State.SEPARATED, 1_700_000_000_000L); } From 217836c2dd6accdadfeac6a7afb54c54e9772070 Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:37:29 +0200 Subject: [PATCH 38/61] Keep listening while the app is closed, and say when a tag is left behind Everything else in this app listens only while a screen is open, which makes it a display feature: it tells you what is near you while you are looking. The question a tag is actually for - where did I leave it - is asked at the moment the app is shut. So this adds a service that keeps listening, behind a setting that is off until somebody turns it on. Opt-in is not caution for its own sake. Turning it on starts a foreground service with a permanent notification, records where tags were heard, and costs battery. People who install this app to stay off Apple's tracking network have reasons to decide that themselves. **Position only at the two edges.** A tag that keeps being heard is with whoever holds the phone, so a position taken then records where the *user* went, and records "still here" over and over. Only arriving somewhere and going quiet carry information. Between them nothing is read and nothing is written, which also stops the location indicator flashing every few seconds - and CachedPhoneLocation collapses what is left, widening the accuracy it claims by the age of the fix so a stale position is not passed off as a tight one. **The left-behind alert is verified, not timed.** Silence from a scan is not evidence: a 66-second gap was measured with a tag simply carried in a pocket, and an alert did fire twenty minutes into a walk on a 90-second threshold. So the timer only decides when to *look*: after 30 seconds of quiet the radio listens hard for six, targeted at that tag, and only silence then earns an alert. That makes a short threshold safe - about forty seconds end to end instead of two and a half minutes, which is the difference between hearing about the cafe on the pavement and on the bus. Scanning runs at SCAN_MODE_BALANCED, the same as the screens. Low power was the obvious choice for a service running all day, a tenth of the radio time against a quarter, but it produced the gaps above - and fewer gaps means fewer full-power verification bursts, so the cheap mode is not obviously the cheap answer. Which actually costs less has not been measured; worth offering as a choice once it has been. Two platform rules cost a build each and are recorded where they bit: setSilent on the foreground notification files it under "Silent", where it gets no status bar icon - a service running with nothing to see, which is what the notification exists to prevent. The channel's own LOW importance already means no sound. Starting at boot cannot use the location foreground-service type: the BOOT_COMPLETED exemption covers starting a service from the background, not using a while-in-use permission with nothing visible. It threw, START_STICKY restarted it into the same throw, and Android gave up on the app. It now falls back to the connected-device type, which scanning is by any reading, and the alert no longer depends on having a position at all. Positions are therefore missing after a reboot until the app is next opened. Fixing that needs ACCESS_BACKGROUND_LOCATION, which is a deliberate no for now. --- app/src/main/AndroidManifest.xml | 52 ++ .../AccessorySightingPersister.java | 60 +- .../opentagviewer/DeviceInfoActivity.java | 4 +- .../android/opentagviewer/MapsActivity.java | 4 +- .../opentagviewer/OpenAirTagApplication.java | 35 + .../opentagviewer/SettingsActivity.java | 98 +++ .../opentagviewer/ble/NearbyTagWatcher.java | 41 +- .../db/datastore/UserSettingsDataStore.java | 1 + .../db/repo/BeaconRepository.java | 30 + .../db/repo/UserSettingsRepository.java | 4 + .../db/repo/model/UserSettings.java | 25 + .../opentagviewer/service/BootReceiver.java | 59 ++ .../service/NearbyScanService.java | 630 ++++++++++++++++++ .../opentagviewer/util/LeftBehind.java | 78 +++ .../util/android/CachedPhoneLocation.java | 111 +++ app/src/main/res/layout/activity_settings.xml | 45 ++ app/src/main/res/values-de/strings.xml | 10 + app/src/main/res/values-en/strings.xml | 10 + app/src/main/res/values-fr/strings.xml | 10 + app/src/main/res/values-ja/strings.xml | 10 + app/src/main/res/values-ko/strings.xml | 10 + app/src/main/res/values-nl/strings.xml | 10 + app/src/main/res/values-ru/strings.xml | 10 + app/src/main/res/values-zh-rCN/strings.xml | 10 + app/src/main/res/values-zh-rTW/strings.xml | 10 + app/src/main/res/values/strings.xml | 10 + .../ble/NearbyTagWatcherTest.java | 4 +- .../opentagviewer/util/LeftBehindTest.java | 76 +++ .../util/android/CachedPhoneLocationTest.java | 110 +++ 29 files changed, 1504 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/service/BootReceiver.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/LeftBehind.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/android/CachedPhoneLocation.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/util/LeftBehindTest.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/util/android/CachedPhoneLocationTest.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7672151f..31ac771c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,6 +21,32 @@ + + + + + + + + + + @@ -79,6 +105,32 @@ android:foregroundServiceType="location"> + + + + + + + + + + Not the position. A sighting means the tag is with whoever is holding the phone, so + * writing a position for every one of them records where the user went, not where the tag + * is - and does it while the answer is "still here". {@code NearbyScanService} writes a position + * at the two moments that carry information instead: when a tag turns up, and when it stops + * being heard. + * + *

Public rather than package-private since {@code NearbyScanService} joined the two screens + * as a caller. The point of the class is that there is exactly one of these, and a service in + * another package needing its own copy of the policy would be the failure it exists to prevent. * *

One class because the policy used to live in four hand-copied methods - a * {@code correctAlignmentFromSighting} and a {@code keepWhatTheSightingProved} in each of @@ -31,21 +40,13 @@ *

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 { +public final class AccessorySightingPersister { private static final String TAG = AccessorySightingPersister.class.getSimpleName(); private 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) { + public AccessorySightingPersister(final BeaconRepository beaconRepo) { this.beaconRepo = beaconRepo; - this.phoneLocation = phoneLocation; } /** @@ -58,10 +59,9 @@ final class AccessorySightingPersister { * user with no Apple device nothing else will ever report it - see * {@code BeaconRepository#storeLastSighting}. */ - void onSighting(final NearbyTagSighting sighting, final String mac) { + public void onSighting(final NearbyTagSighting sighting, final String mac) { this.maybeCorrectAlignment(sighting, mac); this.persistLastSighting(sighting); - this.persistPosition(sighting); } /** @@ -115,36 +115,6 @@ 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 f1c30eca..aea1fe30 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.CachedPhoneLocation; import dev.wander.android.opentagviewer.util.android.FusedPhoneLocation; import dev.wander.android.opentagviewer.util.android.PropertiesUtil; import dev.wander.android.opentagviewer.util.android.WebLink; @@ -219,8 +220,7 @@ protected void onCreate(Bundle savedInstanceState) { this.beaconRepo = new BeaconRepository( OpenTagViewerDatabase.getInstance(getApplicationContext())); - this.sightingPersister = new AccessorySightingPersister( - this.beaconRepo, new FusedPhoneLocation(this.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); 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 2af846dd..8afc6dba 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.CachedPhoneLocation; import dev.wander.android.opentagviewer.util.android.FusedPhoneLocation; import dev.wander.android.opentagviewer.util.LogCollectorUtil; import dev.wander.android.opentagviewer.util.MapUtils; @@ -497,8 +498,7 @@ protected void onCreate(Bundle savedInstanceState) { this.beaconRepo = new BeaconRepository( OpenTagViewerDatabase.getInstance(getApplicationContext())); - this.sightingPersister = new AccessorySightingPersister( - this.beaconRepo, new FusedPhoneLocation(this.getApplicationContext())); + this.sightingPersister = new AccessorySightingPersister(this.beaconRepo); this.fusedLocationClient = LocationServices.getFusedLocationProviderClient(this); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java b/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java index 6d2effde..5d1f543f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java @@ -10,7 +10,10 @@ import dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore; import dev.wander.android.opentagviewer.db.repo.UserSettingsRepository; +import dev.wander.android.opentagviewer.db.repo.model.UserSettings; import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.service.NearbyScanService; +import io.reactivex.rxjava3.schedulers.Schedulers; public class OpenAirTagApplication extends PyApplication { private static final String TAG = OpenAirTagApplication.class.getSimpleName(); @@ -25,6 +28,38 @@ public void onCreate() { this.setupTheme(); this.setupSystemColors(); + this.resumeBackgroundScanIfEnabled(); + } + + /** + * Brings the background scan back after the process was gone. + * + *

The setting is the state, and the service is only its consequence. A service does + * not survive a reboot, a force-stop or the system reclaiming memory, so without this the + * switch would silently stop meaning anything and the only cure would be toggling it off and + * on - which reads as the setting having been forgotten. + * + *

Off by default, so this starts nothing for anyone who has not asked. Read + * asynchronously because the setting lives in a DataStore and Application#onCreate blocks + * the first activity. + */ + private void resumeBackgroundScanIfEnabled() { + // Off the main thread: the read hits a DataStore, and this runs before the first + // activity is created. + Schedulers.io().scheduleDirect(() -> { + try { + final UserSettings settings = + new UserSettingsRepository(UserSettingsDataStore.getInstance(this)) + .getUserSettings(); + + if (settings.shouldScanInBackground()) { + Log.i(TAG, "Background scanning is on; starting the service"); + NearbyScanService.start(this); + } + } catch (final Exception e) { + Log.w(TAG, "Could not read whether to scan in the background", e); + } + }); } /** diff --git a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java index 325e2bd2..5f221a5e 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java @@ -1,5 +1,9 @@ package dev.wander.android.opentagviewer; +import androidx.core.content.ContextCompat; +import androidx.core.app.ActivityCompat; +import android.content.pm.PackageManager; +import android.Manifest; import static android.view.View.GONE; import static android.view.View.VISIBLE; import static android.view.View.inflate; @@ -52,6 +56,7 @@ import java.util.Set; import java.util.stream.Collectors; +import dev.wander.android.opentagviewer.service.NearbyScanService; import dev.wander.android.opentagviewer.anisette.AdiLibraryImporter; import dev.wander.android.opentagviewer.anisette.AdiLibraryManifest; import dev.wander.android.opentagviewer.anisette.AnisetteSource; @@ -192,6 +197,7 @@ protected void onCreate(Bundle savedInstanceState) { this.binding.setCurrentMapProvider(this.getCurrentMapProviderUiString()); this.binding.setIsDebugDataEnabled(Optional.ofNullable(this.currentSettings.getEnableDebugData()).orElse(false)); this.binding.setIsShowAppleDevicesEnabled(this.currentSettings.shouldShowAppleDevices()); + this.binding.setIsScanInBackgroundEnabled(this.currentSettings.shouldScanInBackground()); this.binding.setOnClickAppleDevicesHelpLink(this::onClickAppleDevicesHelpLink); this.binding.setIsSystemColorsSupported(DynamicColors.isDynamicColorAvailable()); this.binding.setIsSystemColorsEnabled( @@ -207,6 +213,9 @@ protected void onCreate(Bundle savedInstanceState) { MaterialSwitch appleDevices = this.findViewById(R.id.settings_show_apple_devices); appleDevices.setOnCheckedChangeListener(this::onShowAppleDevicesChange); + MaterialSwitch backgroundScan = this.findViewById(R.id.settings_scan_in_background); + backgroundScan.setOnCheckedChangeListener(this::onScanInBackgroundChange); + MaterialSwitch systemColors = this.findViewById(R.id.settings_app_use_system_colors); systemColors.setOnCheckedChangeListener(this::onUseSystemColorsChange); @@ -263,6 +272,95 @@ private void onShowAppleDevicesChange(CompoundButton buttonView, boolean isCheck + "; they are " + (isChecked ? "also" : "no longer") + " searched for"); } + /** + * Starts or stops the background scan, and saves the choice. + * + *

Acted on immediately rather than at the next launch, unlike its neighbour above. + * Somebody turning this on is asking for something to start happening, and somebody turning + * it off is asking for it to stop - most likely because they have just seen the notification + * and want it gone. Deferring either would read as the switch not working. + */ + private void onScanInBackgroundChange(CompoundButton buttonView, boolean isChecked) { + if (this.currentSettings.shouldScanInBackground() == isChecked) { + return; + } + + this.currentSettings.setScanInBackground(isChecked); + this.binding.setIsScanInBackgroundEnabled(isChecked); + this.saveSettings(); + + if (isChecked) { + this.askToShowTheNotification(); + NearbyScanService.start(this); + } else { + NearbyScanService.stop(this); + } + + Log.i(TAG, "Background scanning is now " + (isChecked ? "on" : "off")); + } + + /** + * Asks for permission to show the service's notification, on the versions that require it. + * + *

The service runs either way, and that is the problem. Android 13 made + * notifications a runtime permission, and a foreground service whose notification is + * suppressed still scans - so somebody who turned this on would get the battery cost and no + * sign that anything was happening, which is the one thing a permanent notification is for. + * + *

Asked at the moment it becomes relevant rather than at startup: a prompt at first + * launch, before anything wants to notify, is one people dismiss without reading. Nothing + * hangs on the answer - a refusal leaves the service running and invisible, which is the + * user's call to make. + */ + private void askToShowTheNotification() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return; + } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED) { + return; + } + + ActivityCompat.requestPermissions( + this, new String[] {Manifest.permission.POST_NOTIFICATIONS}, + NOTIFICATION_PERMISSION_REQUEST_CODE); + } + + /** Request code for {@link #askToShowTheNotification()}. Nothing depends on the answer. */ + private static final int NOTIFICATION_PERMISSION_REQUEST_CODE = 2001; + + /** + * Re-posts the service's notification once permission arrives. + * + *

Because the grant lands after the service has already started. The dialog is + * asynchronous, so the service goes to the foreground while notifications are still denied, + * and the system drops the notification it posts. Nothing re-posts it afterwards, so the + * service scans invisibly for the rest of its life - permission granted, status bar empty, + * which is exactly the state this setting must not leave somebody in. + * + *

Starting an already-running service is cheap and safe: it re-enters + * {@code onStartCommand}, which posts the notification again and leaves the existing scan + * alone. + */ + @Override + public void onRequestPermissionsResult( + final int requestCode, final String[] permissions, final int[] grantResults) { + + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode != NOTIFICATION_PERMISSION_REQUEST_CODE) { + return; + } + + final boolean granted = grantResults.length > 0 + && grantResults[0] == PackageManager.PERMISSION_GRANTED; + + if (granted && this.currentSettings.shouldScanInBackground()) { + Log.i(TAG, "Notification permission granted; re-posting the service notification"); + NearbyScanService.start(this); + } + } + /** * Opens the issue tracking real support for locating the owner's own devices. * 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 f808845d..9f11b597 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 @@ -28,18 +28,16 @@ /** * 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. + *

Who runs it decides what it costs. A screen subscribes in {@code onResume} and + * disposes in {@code onPause}, so the radio is on only while somebody is looking - that is the + * default, and it keeps the app a display feature. {@code NearbyScanService} runs the same class + * continuously when the user turns background scanning on, which is a recording feature and is + * why it is opt-in and carries a permanent notification. * - *

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. + *

The difference reaches this class as {@link #scanMode}: a screen wants results promptly, + * a service running all day wants the cheapest duty cycle the platform offers. * - *

{@code SCAN_MODE_BALANCED} - a middle ground between the low-latency mode + *

The screen's {@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 @@ -104,21 +102,40 @@ public interface SightingListener { * advertisement that arrives while the first is still running. */ private final AtomicBoolean indexRebuildInFlight = new AtomicBoolean(false); + /** + * How hard the radio listens. + * + *

{@code SCAN_MODE_BALANCED} for a screen, {@code SCAN_MODE_LOW_POWER} for the service. + * The difference is the duty cycle: low power leaves longer gaps between listening windows, + * so a tag takes longer to be noticed - acceptable when nobody is watching the screen, and + * not acceptable when they are. + */ + private final int scanMode; + public NearbyTagWatcher(final AccessoryMacResolver macResolver) { this(macResolver, null); } public NearbyTagWatcher( final AccessoryMacResolver macResolver, @Nullable final SightingListener listener) { - this(macResolver, listener, new NearbyTagIndex(), System::currentTimeMillis); + this(macResolver, listener, ScanSettings.SCAN_MODE_BALANCED); + } + + public NearbyTagWatcher( + final AccessoryMacResolver macResolver, + @Nullable final SightingListener listener, + final int scanMode) { + this(macResolver, listener, scanMode, new NearbyTagIndex(), System::currentTimeMillis); } NearbyTagWatcher(final AccessoryMacResolver macResolver, @Nullable final SightingListener sightingListener, + final int scanMode, final NearbyTagIndex index, final Clock clock) { this.macResolver = macResolver; this.sightingListener = sightingListener; + this.scanMode = scanMode; this.index = index; this.clock = clock; } @@ -211,7 +228,7 @@ public void onScanFailed(final int errorCode) { new byte[]{(byte) 0xFF}) .build()); final ScanSettings settings = new ScanSettings.Builder() - .setScanMode(ScanSettings.SCAN_MODE_BALANCED) + .setScanMode(this.scanMode) .build(); scanner.startScan(findMyFramesOnly, settings, callback); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java b/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java index 4e29e662..e50e56fd 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java @@ -29,6 +29,7 @@ public final class UserSettingsDataStore { public static final Preferences.Key ANISETTE_UPGRADE_OFFERED = PreferencesKeys.booleanKey("anisette_upgrade_offered"); public static final Preferences.Key SHOW_APPLE_DEVICES = PreferencesKeys.booleanKey("show_apple_devices"); public static final Preferences.Key ICLOUD_OFFER_MADE = PreferencesKeys.booleanKey("icloud_offer_made"); + public static final Preferences.Key SCAN_IN_BACKGROUND = PreferencesKeys.booleanKey("scan_in_background"); public static RxDataStore getInstance(Context context) { if (PREFERENCES_DATA_STORE == 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 875f0a4c..9c9fcfb5 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 @@ -701,6 +701,36 @@ public Observable recordLocalSighting( */ public static final String LOCAL_REPORT_DESCRIPTION = "Heard over Bluetooth"; + /** + * The key material for every tag worth listening for, keyed by beacon. + * + *

For {@code NearbyScanService}, which has no screen to inherit a loaded model from and + * so has to ask. The screens build the same map out of what they already hold. + * + *

Retired tags are left out, given-up ones are not. A retired tag is one that has + * left the account, so there is nothing to listen for. A tag the network gave up on is the + * opposite case: it stopped being findable over Apple's network, and hearing it directly is + * exactly what could still find it. + * + *

A tag with no {@code accessory_json} is skipped rather than passed on: without key + * material there is nothing to derive an address from, and the watcher would only discard it. + */ + public Observable> getAccessoryJsonByBeaconId() { + return Observable.fromCallable(() -> { + final Map byBeaconId = new HashMap<>(); + + for (final OwnedBeacon beacon : db.ownedBeaconDao().getAll()) { + if (beacon.isRemoved || beacon.accessoryJson == null + || beacon.accessoryJson.isEmpty()) { + continue; + } + byBeaconId.put(beacon.id, beacon.accessoryJson); + } + + return byBeaconId; + }).subscribeOn(Schedulers.io()); + } + /** * The last thing heard from this tag over Bluetooth, or empty if it never has been. * diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java index 92192da0..6ad4124f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java @@ -9,6 +9,7 @@ import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.ICLOUD_OFFER_MADE; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.LANGUAGE; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.MAP_PROVIDER; +import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.SCAN_IN_BACKGROUND; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.SHOW_APPLE_DEVICES; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.USE_DARK_THEME; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.USE_SYSTEM_COLORS; @@ -43,6 +44,7 @@ public UserSettings getUserSettings() { String anisetteApkUri = settings.get(ANISETTE_APK_URI); Boolean anisetteUpgradeOffered = settings.get(ANISETTE_UPGRADE_OFFERED); Boolean showAppleDevices = settings.get(SHOW_APPLE_DEVICES); + Boolean scanInBackground = settings.get(SCAN_IN_BACKGROUND); Boolean icloudOfferMade = settings.get(ICLOUD_OFFER_MADE); return UserSettings.builder() @@ -57,6 +59,7 @@ public UserSettings getUserSettings() { .anisetteApkUri(anisetteApkUri) .anisetteUpgradeOffered(anisetteUpgradeOffered) .showAppleDevices(showAppleDevices) + .scanInBackground(scanInBackground) .icloudOfferMade(icloudOfferMade) .build(); @@ -107,6 +110,7 @@ public Completable storeUserSettings(UserSettings userSettings) { // Null reads as off, which is the intended default - the app shows only what it can // actually keep up to date. See UserSettings.showAppleDevices. mutablePreferences.set(SHOW_APPLE_DEVICES, userSettings.shouldShowAppleDevices()); + mutablePreferences.set(SCAN_IN_BACKGROUND, userSettings.shouldScanInBackground()); // Once true this never goes back to false: somebody who dismissed the offer has // answered it, and asking again is how a prompt becomes something people close // without reading. See UserSettings.icloudOfferMade. diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java index 2aa50b4e..ea0bef48 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java @@ -118,6 +118,23 @@ public class UserSettings { */ private Boolean icloudOfferMade; + /** + * Whether to keep listening for the user's tags while the app is closed. + * + *

Off unless somebody turns it on, and this one changes what the app is. Without + * it the radio only listens while a screen is open, which makes this a display feature: it + * tells you what is near you while you are looking. With it the app runs a foreground + * service with a permanent notification, listens continuously, and writes down where your + * tags were heard - which is a recording feature, and one this app's users have specific + * reasons to want to opt into rather than receive. + * + *

It is also what makes the local position history worth having: the case a history + * answers is "where did I leave it", and the app is shut at exactly that moment. + * + *

Null reads as off. See {@link #shouldScanInBackground()}. + */ + private Boolean scanInBackground; + public static final String ANISETTE_LOCAL = "local"; public static final String ANISETTE_REMOTE = "remote"; @@ -191,6 +208,14 @@ public boolean shouldShowAppleDevices() { return this.showAppleDevices == Boolean.TRUE; } + /** + * Whether to keep listening while the app is closed - see {@link #scanInBackground}. Null + * means nobody has turned it on, which is off. + */ + public boolean shouldScanInBackground() { + return this.scanInBackground == Boolean.TRUE; + } + /** * Whether to offer connecting an iCloud account. * diff --git a/app/src/main/java/dev/wander/android/opentagviewer/service/BootReceiver.java b/app/src/main/java/dev/wander/android/opentagviewer/service/BootReceiver.java new file mode 100644 index 00000000..80907fb9 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/service/BootReceiver.java @@ -0,0 +1,59 @@ +package dev.wander.android.opentagviewer.service; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore; +import dev.wander.android.opentagviewer.db.repo.UserSettingsRepository; +import dev.wander.android.opentagviewer.db.repo.model.UserSettings; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * Brings the background scan back after the phone restarts. + * + *

Without this the setting quietly stops meaning anything. A service does not survive + * a reboot, and nothing else starts this app on its own - so somebody who turned background + * scanning on would get it until their next restart, and then silence until they happened to + * open the app again. That is the failure mode this whole feature exists to avoid: it is meant + * to be listening precisely when nobody is looking at the app. + * + *

Starting a foreground service from here is allowed, which is not true of most + * background starts on Android 12 and later: {@code BOOT_COMPLETED} is one of the named + * exemptions. + * + *

Reads the setting first and starts nothing for anybody who has not asked. The setting is + * the state; the service is only its consequence. + */ +public class BootReceiver extends BroadcastReceiver { + private static final String TAG = BootReceiver.class.getSimpleName(); + + @Override + public void onReceive(final Context context, final Intent intent) { + if (!Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) { + return; + } + + final Context appContext = context.getApplicationContext(); + + // A receiver's onReceive runs on the main thread and is expected to return promptly, + // and the setting lives in a DataStore. goAsync would keep the process alive for the + // read; starting the service is itself the thing that keeps it alive, so a plain + // scheduler hop is enough here. + Schedulers.io().scheduleDirect(() -> { + try { + final UserSettings settings = + new UserSettingsRepository(UserSettingsDataStore.getInstance(appContext)) + .getUserSettings(); + + if (settings.shouldScanInBackground()) { + Log.i(TAG, "Background scanning is on; starting the service after boot"); + NearbyScanService.start(appContext); + } + } catch (final Exception e) { + Log.w(TAG, "Could not read whether to scan in the background after boot", e); + } + }); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java b/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java new file mode 100644 index 00000000..7cd27e51 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java @@ -0,0 +1,630 @@ +package dev.wander.android.opentagviewer.service; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.os.Build; +import android.os.IBinder; +import android.util.Log; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import java.util.Map; + +import dev.wander.android.opentagviewer.MapsActivity; +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.AccessorySightingPersister; +import dev.wander.android.opentagviewer.ble.BlePermissions; +import dev.wander.android.opentagviewer.ble.NearbyTagWatcher; +import dev.wander.android.opentagviewer.db.repo.BeaconRepository; +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.util.android.CachedPhoneLocation; +import dev.wander.android.opentagviewer.util.android.FusedPhoneLocation; +import dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore; +import dev.wander.android.opentagviewer.db.repo.UserSettingsRepository; +import dev.wander.android.opentagviewer.db.repo.model.UserSettings; +import io.reactivex.rxjava3.schedulers.Schedulers; +import android.text.format.DateUtils; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import dev.wander.android.opentagviewer.util.LeftBehind; +import dev.wander.android.opentagviewer.util.LocalFixWorthKeeping; +import dev.wander.android.opentagviewer.util.android.PhoneLocation; +import io.reactivex.rxjava3.core.Observable; +import dev.wander.android.opentagviewer.ble.NearbyAccessoryScanner; +import java.util.HashMap; +import java.util.List; +import dev.wander.android.opentagviewer.data.model.BeaconInformation; +import dev.wander.android.opentagviewer.db.repo.model.BeaconData; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.util.parse.BeaconDataParser; +import io.reactivex.rxjava3.disposables.Disposable; + +/** + * Keeps listening for the owner's tags while the app is closed. + * + *

Opt-in, and that is not caution for its own sake. Everything else in this app + * listens only while a screen is open, which makes it a display feature. This one records: it + * runs continuously, writes down where tags were heard, and shows a permanent notification for + * as long as it does. The people who install an app to avoid Apple's tracking network have + * specific reasons to decide that for themselves rather than receive it in an update. + * + *

It is also what makes the local position history worth having. The question a + * history answers is "where did I leave it", and the app is shut at exactly that moment. Without + * this, the history records where somebody was while they had the app open, which is mostly at + * home with the tag in their pocket. + * + *

A foreground service because Android gives no other way. A background scan without + * one is throttled to the point of uselessness, and from Android 14 the service must declare + * what it is for - {@code location}, since it reads the phone's position to attribute a sighting. + * The permanent notification is the price of that, and it is honest: something is listening. + * + *

Same {@link NearbyTagWatcher} and the same {@code SCAN_MODE_BALANCED} the screens use. + * Low power was the obvious choice for a service that runs all day - a tenth of the radio time + * against a quarter - but it produced gaps of over a minute while a tag sat in a pocket, and a + * gap is what the left-behind rule has to see through. Fewer gaps also means fewer verification + * bursts at full power, so the cheaper mode is not obviously the cheaper answer. + * + *

Which of the two actually costs less has not been measured yet, and the duty cycles + * alone do not settle it. Worth making the user's choice once there is a number to put beside + * it. + */ +public class NearbyScanService extends Service { + private static final String TAG = NearbyScanService.class.getSimpleName(); + + private static final String CHANNEL_ID = "nearby_scan"; + private static final int NOTIFICATION_ID = 4711; + + /** + * Sent when the user swipes the notification away. + * + *

Since Android 14 that gesture is available even on a foreground service, and it + * removes only the notification. The service keeps scanning, invisibly, which is exactly + * the state a permanent notification exists to prevent. So the swipe is read as what it + * plainly means - stop doing this - and turns the setting off too, leaving the switch in + * Settings agreeing with reality. + */ + private static final String ACTION_DISMISSED = "dev.wander.opentagviewer.SCAN_DISMISSED"; + + /** Channel for the left-behind alert, which is loud on purpose - see {@link #alertLeftBehind}. */ + private static final String ALERT_CHANNEL_ID = "tag_left_behind"; + + /** + * How often the left-behind rule is evaluated. + * + *

This is latency, not work. The check is arithmetic over a handful of tags and + * touches the radio only for one that has gone quiet - but whatever it is, it is added to + * every alert. At a minute it was the largest single delay in the chain, longer than the + * silence it was watching for. + */ + private static final long CHECK_INTERVAL_MS = 15_000L; + + /** What is known about a tag right now: heard since when, and where it turned up. */ + private static final class Presence { + private long lastHeardMs; + private final Double appearedLatitude; + private final Double appearedLongitude; + private boolean gone; + + private Presence(final long lastHeardMs, final Double latitude, final Double longitude) { + this.lastHeardMs = lastHeardMs; + this.appearedLatitude = latitude; + this.appearedLongitude = longitude; + } + } + + /** + * Per tag, when it was last heard and where this phone was then. + * + *

In memory rather than in the database, and lost on a restart on purpose: the rule is + * about a walk somebody is taking right now. A stale entry from before a reboot would fire + * as soon as the service came back somewhere else, which is the phone having moved rather + * than a tag having been left. + */ + private final Map presence = new ConcurrentHashMap<>(); + + private BeaconRepository beaconRepo; + + /** The key material the watch was started with, for the verification scan. */ + private Map accessoryJsonByBeaconId = Map.of(); + + /** + * What to call each tag, read once when the watch starts. + * + *

The same name the screens show - the user's own nickname where they set one, Apple's + * otherwise. An alert that names a beacon id tells somebody a tag is missing without telling + * them which, which is most of the message gone. + */ + private Map namesByBeaconId = Map.of(); + private AccessorySightingPersister sightingPersister; + private PhoneLocation phoneLocation; + + @Nullable + private Disposable watch; + + /** The periodic left-behind check, running for as long as the service does. */ + @Nullable + private Disposable leftBehindCheck; + + /** Starts the service, or does nothing if it is already running. */ + public static void start(final Context context) { + final Intent intent = new Intent(context.getApplicationContext(), NearbyScanService.class); + context.getApplicationContext().startForegroundService(intent); + } + + /** Stops the service and its scan. Safe to call when it is not running. */ + public static void stop(final Context context) { + final Intent intent = new Intent(context.getApplicationContext(), NearbyScanService.class); + context.getApplicationContext().stopService(intent); + } + + @Nullable + @Override + public IBinder onBind(final Intent intent) { + return null; + } + + @Override + public void onCreate() { + super.onCreate(); + + this.beaconRepo = new BeaconRepository( + OpenTagViewerDatabase.getInstance(this.getApplicationContext())); + this.phoneLocation = new CachedPhoneLocation( + new FusedPhoneLocation(this.getApplicationContext())); + this.sightingPersister = new AccessorySightingPersister(this.beaconRepo); + } + + @Override + public int onStartCommand(final Intent intent, final int flags, final int startId) { + // Logged because a restart wipes the presence map, and a tag heard again afterwards is + // then a fresh arrival that can alert a second time. Two alerts for one departure looked + // like a false alarm and could not be told apart from one after the fact. + Log.i(TAG, "onStartCommand: action=" + (intent == null ? "restart" : intent.getAction())); + + if (intent != null && ACTION_DISMISSED.equals(intent.getAction())) { + this.turnBackgroundScanningOff(); + return START_NOT_STICKY; + } + + this.goToForeground(); + + if (this.watch == null || this.watch.isDisposed()) { + this.startWatching(); + } + + // Restarted if the system kills it for memory, which is what somebody who turned this + // on is asking for. Without a redelivered intent: there is no work in it, the state + // lives in the setting. + return START_STICKY; + } + + @Override + public void onDestroy() { + if (this.watch != null && !this.watch.isDisposed()) { + this.watch.dispose(); + } + this.watch = null; + if (this.leftBehindCheck != null && !this.leftBehindCheck.isDisposed()) { + this.leftBehindCheck.dispose(); + } + this.leftBehindCheck = null; + super.onDestroy(); + } + + /** + * Subscribes the watch, over every tag that has usable key material. + * + *

Silent when it cannot run - no Bluetooth permission, radio off, or nothing backfilled + * yet - for the same reason the screens are: there is nothing the user can do about it from + * here, and a service that cannot scan should sit quietly rather than complain. + */ + private void startWatching() { + if (!BlePermissions.granted(this)) { + Log.d(TAG, "Not scanning in the background: BLE permission not granted"); + return; + } + + this.watch = this.beaconRepo.getAllBeacons() + .subscribeOn(Schedulers.io()) + .subscribe(beacons -> { + this.namesByBeaconId = readNames(beacons); + this.watchThese(keyMaterialOf(beacons)); + }, error -> Log.w(TAG, "Could not read the tags to watch for", error)); + } + + /** + * The key material worth listening for, keyed by beacon. + * + *

Retired tags are left out - there is nothing to listen for - but tags the network gave + * up on are kept: those stopped being findable over Apple's network, and hearing one + * directly is exactly what could still find it. + */ + private static Map keyMaterialOf(final List beacons) { + final Map byBeaconId = new HashMap<>(); + + for (final BeaconData beacon : beacons) { + final OwnedBeacon owned = beacon.getOwnedBeaconInfo(); + + if (owned == null || owned.isRemoved + || owned.accessoryJson == null || owned.accessoryJson.isEmpty()) { + continue; + } + byBeaconId.put(beacon.getBeaconId(), owned.accessoryJson); + } + + return byBeaconId; + } + + /** + * Display names, through the same parser the screens use. + * + *

Doing it here rather than reading a column: a name can come from the user's override, + * from Apple's naming record, or out of the accessory JSON for a tag that was never in an + * account, and {@code BeaconDataParser} is where that precedence already lives. A second + * implementation of it would eventually disagree with the one on screen. + */ + private static Map readNames(final List beacons) { + final Map names = new HashMap<>(); + + try { + for (final BeaconInformation information : BeaconDataParser.parse(beacons)) { + if (information.getName() != null && !information.getName().isBlank()) { + names.put(information.getBeaconId(), information.getName()); + } + } + } catch (final Exception e) { + // A tag with no name still deserves its alert, and the beacon id is at least true. + Log.w(TAG, "Could not read the tag names; alerts will name beacon ids", e); + } + + return names; + } + + private void watchThese(final Map accessoryJsonByBeaconId) { + if (accessoryJsonByBeaconId.isEmpty()) { + Log.d(TAG, "Not scanning in the background: no tags with key material"); + return; + } + + this.accessoryJsonByBeaconId = accessoryJsonByBeaconId; + + this.watch = new NearbyTagWatcher( + AppDependencies.accessoryMacResolver(), + (sighting, mac) -> { + this.sightingPersister.onSighting(sighting, mac); + this.noteHeard(sighting.getBeaconId()); + }, + android.bluetooth.le.ScanSettings.SCAN_MODE_BALANCED) + .watch(this.getApplicationContext(), accessoryJsonByBeaconId) + .subscribe( + sighting -> { }, + error -> Log.w(TAG, "Background watch ended with an error", error), + () -> Log.i(TAG, "Background watch ended")); + + this.leftBehindCheck = Observable + .interval(CHECK_INTERVAL_MS, CHECK_INTERVAL_MS, TimeUnit.MILLISECONDS, + Schedulers.io()) + .subscribe(tick -> this.checkForLeftBehind(), + error -> Log.w(TAG, "The left-behind check stopped", error)); + } + + /** + * Notes that a tag was heard, and reads the position only if it had been away. + * + *

Nothing is read or written while a tag keeps being heard. A tag in range is with + * whoever is holding the phone, so a position taken then describes where the user + * went - it tracks a person rather than a thing, and records "still here" over and over. The + * two moments that carry information are the edges: a tag turning up somewhere, and a tag + * going quiet. + * + *

Turning up again also re-arms the alert. A tag that comes back is one that came along, + * and the next time it goes quiet is a new event worth its own alert. + */ + private void noteHeard(final String beaconId) { + final long now = System.currentTimeMillis(); + final Presence known = this.presence.get(beaconId); + + if (known != null && !known.gone) { + known.lastHeardMs = now; + return; + } + + final PhoneLocation.Fix fix = this.phoneLocation.lastKnown(); + + this.presence.put(beaconId, new Presence(now, + fix == null ? null : fix.getLatitude(), + fix == null ? null : fix.getLongitude())); + + if (fix != null) { + this.beaconRepo.recordLocalSighting(beaconId, fix.getLatitude(), fix.getLongitude(), + fix.getAccuracyMetres(), 0, now) + .subscribe(written -> { }, error -> + Log.w(TAG, "Could not record where beaconId=" + beaconId + + " turned up", error)); + } + + Log.d(TAG, "beaconId=" + beaconId + " is in range again"); + } + + /** + * Alerts once for each tag that has gone quiet while this phone moved on. + * + *

Needs a position now as well as then - without one there is no way to tell walking away + * from standing still, and silence on its own is not worth waking somebody for. See + * {@link LeftBehind} for the rule and why it is deliberately hard to satisfy. + */ + private void checkForLeftBehind() { + final long now = System.currentTimeMillis(); + + for (final Map.Entry entry : this.presence.entrySet()) { + final Presence known = entry.getValue(); + + if (known.gone || now - known.lastHeardMs < LeftBehind.QUIET_FOR_MS) { + continue; + } + + // Quiet long enough to be worth checking. This is the second of the two edges, and + // the only other moment the position is worth reading. + known.gone = true; + + // **A missing position must not swallow the alert.** It used to, left over from when + // distance was half the rule; the verification scan decides now, and "your keys are + // not with you" is worth saying whether or not the phone can say where. It also + // happens to be the state the service is in after a reboot until the app is next + // opened, so the gate turned the whole feature off exactly when it was meant to be + // working on its own. + this.verifyThenAlert(entry.getKey(), known, this.phoneLocation.lastKnown(), now); + } + } + + /** + * Listens hard for one tag before saying it is gone. + * + *

Silence from a low-power scan is not evidence. {@code SCAN_MODE_LOW_POWER} + * listens for roughly half a second in five, so a tag in a pocket with a body in the way + * misses windows in runs - a gap of 66 seconds was measured while carrying one, against a + * threshold of 90. Any threshold short enough to be useful while walking out of a cafe sits + * inside that noise, and the alert that fired 20 minutes into a walk was exactly this: the + * tag was in the pocket the whole time. + * + *

So the timer no longer decides. When it runs out, the radio listens properly for a few + * seconds - the same targeted, low-latency scan the ring button uses - and only silence + * then earns an alert. It costs one short burst per suspicion instead of running the + * radio hard all day, and it makes a short threshold safe: about a minute of quiet plus a + * few seconds of listening, rather than five minutes of waiting and still being wrong. + */ + private void verifyThenAlert(final String beaconId, final Presence known, + @Nullable final PhoneLocation.Fix here, final long nowMs) { + + final String accessoryJson = this.accessoryJsonByBeaconId.get(beaconId); + if (accessoryJson == null) { + return; + } + + final Map candidates = + AppDependencies.accessoryMacResolver().currentMacAddresses(accessoryJson); + + if (candidates == null || candidates.isEmpty()) { + return; + } + + NearbyAccessoryScanner + .findNearby(this.getApplicationContext(), candidates.keySet(), VERIFY_SCAN_MS) + .subscribeOn(Schedulers.io()) + .subscribe( + device -> { + // It was a gap. Put the tag back to present so the next silence is + // judged from here rather than from before the burst. + known.gone = false; + known.lastHeardMs = System.currentTimeMillis(); + Log.d(TAG, "beaconId=" + beaconId + + " answered the verification scan; no alert"); + }, + notNearby -> { + if (here != null) { + this.recordContactLost(beaconId, known, here, nowMs); + } + this.alertLeftBehind(beaconId, known.lastHeardMs); + }); + } + + /** + * How long the verification scan listens. + * + *

A tag in range advertises every second or two, so a few seconds of low-latency + * listening hears it several times over. Long enough to be conclusive, short enough that the + * burst costs nothing next to the day the radio spends idling. + */ + private static final long VERIFY_SCAN_MS = 6_000L; + + /** + * Writes where contact with a tag was lost, as well as it can be known. + * + *

The position is where the phone is now, and the accuracy says how little that is + * worth. Contact could have been lost anywhere in the quiet window - five minutes of + * walking is several hundred metres - so the row claims that whole radius rather than the + * metres the fix itself would claim. A tight circle drawn around where somebody noticed the + * silence would be the app inventing a place it never observed. + * + *

Nothing is written when the phone has not moved: the tag going quiet on a desk beside + * somebody is a radio gap, not a place worth recording. + */ + private void recordContactLost(final String beaconId, final Presence known, + final PhoneLocation.Fix here, final long nowMs) { + + if (known.appearedLatitude != null && LocalFixWorthKeeping.metresBetween( + known.appearedLatitude, known.appearedLongitude, + here.getLatitude(), here.getLongitude()) < LocalFixWorthKeeping.MOVED_METRES) { + return; + } + + final long couldBeAnywhereWithin = here.getAccuracyMetres() + + Math.round((LeftBehind.QUIET_FOR_MS / 1000.0) * WALKING_METRES_PER_SECOND); + + this.beaconRepo.recordLocalSighting(beaconId, here.getLatitude(), here.getLongitude(), + couldBeAnywhereWithin, 0, nowMs) + .subscribe(written -> { }, error -> + Log.w(TAG, "Could not record where contact with beaconId=" + beaconId + + " was lost", error)); + } + + /** Walking pace, for turning the quiet window into the radius it implies. */ + private static final double WALKING_METRES_PER_SECOND = 1.4; + + /** + * The one notification in this app that is allowed to interrupt. + * + *

High importance, with sound. Everything else here is a status line somebody can + * find when they go looking; this is the opposite - it is only useful in the half minute + * while walking away is still reversible, and a silent entry in the shade would be read + * hours later at home. That is also why the rule behind it is strict: an alert that cries + * wolf gets switched off, and then it is not there on the day it matters. + */ + private void alertLeftBehind(final String beaconId, final long lastHeardMs) { + final NotificationManager manager = this.getSystemService(NotificationManager.class); + + if (manager.getNotificationChannel(ALERT_CHANNEL_ID) == null) { + final NotificationChannel channel = new NotificationChannel( + ALERT_CHANNEL_ID, + this.getString(R.string.left_behind_channel), + NotificationManager.IMPORTANCE_HIGH); + channel.setDescription(this.getString(R.string.left_behind_channel_description)); + channel.enableVibration(true); + manager.createNotificationChannel(channel); + } + + final Intent open = new Intent(this, MapsActivity.class) + .putExtra("beaconId", beaconId); + + final PendingIntent show = PendingIntent.getActivity( + this, beaconId.hashCode(), open, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + + final CharSequence howLongAgo = DateUtils.getRelativeTimeSpanString( + lastHeardMs, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS); + + final Notification alert = new NotificationCompat.Builder(this, ALERT_CHANNEL_ID) + .setContentTitle(this.getString(R.string.left_behind_title, + this.namesByBeaconId.getOrDefault(beaconId, beaconId))) + .setContentText(this.getString(R.string.left_behind_text, howLongAgo)) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_REMINDER) + .setContentIntent(show) + .setAutoCancel(true) + .build(); + + // One notification per tag rather than one that replaces the last: leaving two things + // behind is two things to go back for. + manager.notify(beaconId.hashCode(), alert); + + Log.i(TAG, "Alerted that beaconId=" + beaconId + " looks left behind"); + } + + /** + * Turns the setting off and stops, after the notification was swiped away. + * + *

The setting is written, not just the service stopped. Otherwise the switch in + * Settings would still read as on while nothing was running, and the next app start would + * bring the service back - which is the same swipe undone, without the user asking for it. + */ + private void turnBackgroundScanningOff() { + Log.i(TAG, "Notification dismissed; turning background scanning off"); + + Schedulers.io().scheduleDirect(() -> { + try { + final UserSettingsRepository settingsRepo = + new UserSettingsRepository(UserSettingsDataStore.getInstance(this)); + final UserSettings settings = settingsRepo.getUserSettings(); + + settings.setScanInBackground(false); + settingsRepo.storeUserSettings(settings).blockingAwait(); + } catch (final Exception e) { + Log.w(TAG, "Could not turn the background scan setting off", e); + } + }); + + this.stopSelf(); + } + + /** + * The permanent notification, which is what buys the right to keep scanning. + * + *

Low importance: it must be visible, and it must not make a sound or push anything else + * off the screen. Tapping it opens the map, because "what is this doing" and "what has it + * found" are the same question. + * + *

Not {@code setSilent}, which is a different thing from a quiet channel. The + * channel's own {@code IMPORTANCE_LOW} already means no sound. {@code setSilent} additionally + * files the notification under "Silent", where it gets no status bar icon at all - so the + * service ran with nothing to see unless somebody pulled the shade down, which defeats the + * one thing a permanent notification is for. + */ + private void goToForeground() { + final NotificationManager manager = this.getSystemService(NotificationManager.class); + + if (manager.getNotificationChannel(CHANNEL_ID) == null) { + final NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + this.getString(R.string.background_scan_channel), + NotificationManager.IMPORTANCE_LOW); + channel.setDescription(this.getString(R.string.background_scan_channel_description)); + manager.createNotificationChannel(channel); + } + + final PendingIntent open = PendingIntent.getActivity( + this, 0, new Intent(this, MapsActivity.class), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + + final Intent dismissed = new Intent(this, NearbyScanService.class) + .setAction(ACTION_DISMISSED); + final PendingIntent onSwipe = PendingIntent.getService( + this, 1, dismissed, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + + final Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(this.getString(R.string.background_scan_notification_title)) + .setContentText(this.getString(R.string.background_scan_notification_text)) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentIntent(open) + .setDeleteIntent(onSwipe) + .setOngoing(true) + .build(); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + this.startForeground(NOTIFICATION_ID, notification); + return; + } + + // Android 14 wants the type declared at the call site as well as in the manifest. + try { + this.startForeground(NOTIFICATION_ID, notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION + | ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE); + } catch (final SecurityException notEligibleForLocation) { + // **Starting at boot lands here, and it is not a misconfiguration.** Location is a + // foreground-only permission: BOOT_COMPLETED exempts the app from the ban on + // starting a foreground service from the background, but not from the rule that it + // may not *use* a while-in-use permission with nothing visible. Asking for the + // location type then throws, and the throw killed the service - which START_STICKY + // dutifully restarted, into the same throw, until Android gave up on the app. + // + // Scanning is a connected-device job on its own terms, so it carries on as one. The + // position reads simply return nothing until the app is next opened, which + // FusedPhoneLocation already treats as an ordinary answer. + Log.i(TAG, "Not eligible for the location type here; running as connected-device", + notEligibleForLocation); + + this.startForeground(NOTIFICATION_ID, notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE); + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/LeftBehind.java b/app/src/main/java/dev/wander/android/opentagviewer/util/LeftBehind.java new file mode 100644 index 00000000..6dedd7d1 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/LeftBehind.java @@ -0,0 +1,78 @@ +package dev.wander.android.opentagviewer.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Whether a tag looks left behind: heard until recently, quiet now, and the phone has moved on. + * + *

Two conditions, and the second one is what makes this usable. Silence alone proves + * nothing - a tag in a bag, behind a body, or simply quiet during a low-power scan window is + * indistinguishable from one left on a table. Requiring the phone to have moved away since + * the last sighting turns an absence into something worth saying out loud: you are somewhere + * else now, and the tag is not with you. + * + *

Not the same thing as the tag reporting itself separated. A Find My accessory says + * in its own advertisement whether its owner device is near - see {@code FindMyAdvertisement} - + * but for somebody with no Apple device at all, every tag they own is separated all the time. + * That signal answers "is this tag away from its owner's iPhone", which is not the question + * anybody is asking when they walk out of a cafe. + * + *

No Android in here, and the clock and position are parameters, so the rule is covered by a + * JVM test rather than by leaving tags in cafes. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class LeftBehind { + + /** + * How long a tag must be unheard before it counts as gone. + * + *

A trigger, not evidence. It used to be the whole rule, which meant it had to be + * long enough to outlast a radio gap - 90 seconds, and a 66-second gap was measured while + * simply carrying a tag in a pocket. That is late enough to be useless: somebody who has + * left a cafe wants to know before the next street, not after it. + * + *

Since {@code NearbyScanService} verifies with a targeted scan before alerting, silence + * no longer has to prove anything - it only has to be worth checking. Being wrong here costs + * a few seconds of listening, so it can afford to be wrong often. + */ + public static final long QUIET_FOR_MS = 30 * 1000L; + + /** + * How far the phone must have moved from where the tag was last heard. + * + *

Well past the range at which the tag would still be audible, so this cannot fire while + * somebody is still in the same room as it. Roughly the distance from a table to the far + * side of the building, or a minute's walk. + */ + public static final double MOVED_AWAY_METRES = 100.0; + + /** + * True when this tag should be reported as left behind. + * + * @param lastHeardMs when the tag was last heard, or null if it never has been - a tag + * this phone has not met is not one somebody walked away from. + * @param lastHeardLatitude where the phone was then, or null if it had no fix. Without one + * there is no way to tell moving away from standing still, and the + * silence alone is not enough to alert on. + */ + public static boolean looksLeftBehind( + final Long lastHeardMs, + final Double lastHeardLatitude, + final Double lastHeardLongitude, + final long nowMs, + final double latitude, + final double longitude) { + + if (lastHeardMs == null || lastHeardLatitude == null || lastHeardLongitude == null) { + return false; + } + + if (nowMs - lastHeardMs < QUIET_FOR_MS) { + return false; + } + + return LocalFixWorthKeeping.metresBetween( + lastHeardLatitude, lastHeardLongitude, latitude, longitude) >= MOVED_AWAY_METRES; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/android/CachedPhoneLocation.java b/app/src/main/java/dev/wander/android/opentagviewer/util/android/CachedPhoneLocation.java new file mode 100644 index 00000000..fdf7d23d --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/android/CachedPhoneLocation.java @@ -0,0 +1,111 @@ +package dev.wander.android.opentagviewer.util.android; + +import androidx.annotation.Nullable; + +import java.util.concurrent.TimeUnit; + +/** + * A {@link PhoneLocation} that answers from memory between reads. + * + *

Because every read lights the location indicator. Android shows it whenever an app + * touches location, and the sighting path touches it per sighting - once to attribute the + * position, once for the left-behind rule, for every tag in range. With the background service + * running that is a chip blinking in the status bar every few seconds, which reads as the app + * tracking somebody far more aggressively than it is. + * + *

It costs almost no accuracy, because the underlying read was never a fresh fix. + * {@code getLastLocation} hands back whatever position the system already holds; reading it more + * often does not make it newer. Caching changes how often this app asks, not how current + * the answer is. + * + *

The window is a minute, which is well inside the fifteen the write rule waits before + * recording a stationary tag again - so a cached fix cannot suppress a row that a fresh one + * would have written. + * + *

No Android in here on purpose: it decorates the seam rather than the implementation, so the + * expiry rule is covered by a JVM test. + */ +public class CachedPhoneLocation implements PhoneLocation { + + /** How long an answer is reused. See the class doc for why a minute is safe here. */ + static final long FRESH_FOR_MS = TimeUnit.MINUTES.toMillis(1); + + /** + * Metres per second of slack added to a cached fix per second of its age. + * + *

Because a stale position handed back at its original accuracy is a lie. Somebody + * walking covers roughly this much a second, so a minute-old fix can be eighty metres from + * where they are - reported as accurate to eight, which is what the map would draw and what + * anything comparing two reports would believe. + * + *

Walking pace rather than driving: this is an upper bound on the error for the case the + * feature is for, and inflating every fix to motorway distances would make an honest reading + * useless. A fix taken while driving is wider than this says, and the fix's own age is + * recorded either way. + */ + private static final double WALKING_METRES_PER_SECOND = 1.4; + + /** Injectable so the expiry is testable without waiting a minute. */ + interface Clock { + long nowMs(); + } + + private final PhoneLocation delegate; + private final Clock clock; + + @Nullable + private volatile Fix cached; + private volatile long cachedAtMs; + + /** Separate from the timestamp: null is a real answer here, so it must be tellable from + * "never asked". A sentinel timestamp made an empty cache look fresh. */ + private volatile boolean asked; + + public CachedPhoneLocation(final PhoneLocation delegate) { + this(delegate, System::currentTimeMillis); + } + + CachedPhoneLocation(final PhoneLocation delegate, final Clock clock) { + this.delegate = delegate; + this.clock = clock; + } + + @Nullable + @Override + public Fix lastKnown() { + final long now = this.clock.nowMs(); + + if (this.asked && now - this.cachedAtMs < FRESH_FOR_MS) { + return this.cached == null ? null : widenedByAge(this.cached, now - this.cachedAtMs); + } + + final Fix fresh = this.delegate.lastKnown(); + + // **A miss is remembered too.** Location being off, or no fix yet, is a state that lasts + // - retrying it per sighting would light the indicator exactly as often as succeeding, + // for an answer that is not going to change in the next few seconds. + this.cached = fresh; + this.cachedAtMs = now; + this.asked = true; + + return fresh; + } + + /** + * The same position, with the accuracy it can still honestly claim at this age. + * + *

Widening rather than refusing: a position good to a hundred metres is worth keeping and + * says so, while withholding it would leave the sighting with no place at all. The reader + * that cares - {@code BeaconCombinerUtil}, comparing two reports of the same moment - gets + * the number it needs to prefer the better one. + */ + private static Fix widenedByAge(final Fix fix, final long ageMs) { + final long slack = Math.round((ageMs / 1000.0) * WALKING_METRES_PER_SECOND); + + if (slack == 0) { + return fix; + } + + return new Fix(fix.getLatitude(), fix.getLongitude(), fix.getAccuracyMetres() + slack); + } +} diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 7c72d7f2..bc03ae94 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -19,6 +19,7 @@ + @@ -299,6 +300,50 @@ android:textSize="13sp" android:onClick="@{() -> onClickAppleDevicesHelpLink.run()}" /> + + + + + + + + + + Zuletzt gehört Signal gerade eben + Im Hintergrund weiter empfangen + Empfängt deine Tags auch bei geschlossener App und hält fest, wo sie gehört wurden. Braucht eine dauerhafte Benachrichtigung und mehr Akku. Standardmäßig aus. + Hintergrund-Empfang + Wird angezeigt, solange die App im Hintergrund auf deine Tags hört. + Empfängt deine Tags + Zum Öffnen der Karte tippen. + Zurückgelassen + Meldet sich, wenn ein Tag nicht mehr zu hören ist und du dich davon entfernt hast. + %1$s ist zurückgeblieben + Zuletzt dort gehört, wo du %1$s warst. Zum Anzeigen tippen. \ 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 846e00de..ed244d26 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -338,4 +338,14 @@ You can set this up now, or any time later from Settings. Last seen Signal just now + Keep listening in the background + Listens for your tags even when the app is closed, and records where they were heard. Needs a permanent notification, and uses more battery. Off by default. + Background scanning + Shown while the app is listening for your tags in the background. + Listening for your tags + Tap to open the map. + Left behind + Alerts you when a tag stops being heard and you have moved away from it. + %1$s stayed behind + Last heard where you were %1$s. Tap to see the place. \ 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 1132b540..0d30bada 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -338,4 +338,14 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.< Dernière détection Signal à l\'instant + Continuer l\'écoute en arrière-plan + Écoute vos balises même quand l\'application est fermée et note où elles ont été entendues. Nécessite une notification permanente et consomme plus de batterie. Désactivé par défaut. + Écoute en arrière-plan + Affichée tant que l\'application écoute vos balises en arrière-plan. + Écoute de vos balises + Touchez pour ouvrir la carte. + Oublié + Vous alerte quand une balise n\'est plus entendue et que vous vous en êtes éloigné. + %1$s est resté sur place + Entendue pour la dernière fois là où vous étiez %1$s. Touchez pour voir l\'endroit. \ 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 e34acba8..b66c87f6 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -338,4 +338,14 @@ 最後の受信 信号強度 たった今 + バックグラウンドで受信を続ける + アプリを閉じていてもタグを受信し、聞こえた場所を記録します。常時通知が必要で、電池の消費が増えます。既定ではオフです。 + バックグラウンド受信 + アプリがバックグラウンドでタグを受信している間に表示されます。 + タグを受信中 + タップして地図を開きます。 + 置き忘れ + タグが受信できなくなり、その場所から離れたときに知らせます。 + %1$s が置き去りです + %1$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 872f797e..e165a261 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -338,4 +338,14 @@ 마지막 수신 신호 세기 방금 + 백그라운드에서 계속 수신 + 앱을 닫아도 태그를 수신하고 수신한 위치를 기록합니다. 상시 알림이 필요하며 배터리를 더 사용합니다. 기본값은 꺼짐입니다. + 백그라운드 수신 + 앱이 백그라운드에서 태그를 수신하는 동안 표시됩니다. + 태그 수신 중 + 탭하여 지도를 엽니다. + 두고 옴 + 태그가 더 이상 수신되지 않고 그 자리에서 멀어졌을 때 알립니다. + %1$s을(를) 두고 왔습니다 + %1$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 0c5bc62d..68f7cad1 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -338,4 +338,14 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen. Laatst gehoord Signaal zojuist + Op de achtergrond blijven luisteren + Luistert naar je tags ook als de app dicht is en legt vast waar ze gehoord zijn. Vereist een permanente melding en kost meer batterij. Standaard uit. + Achtergrondscan + Wordt getoond zolang de app op de achtergrond naar je tags luistert. + Luistert naar je tags + Tik om de kaart te openen. + Achtergelaten + Waarschuwt je als een tag niet meer te horen is en je ervandaan bent gelopen. + %1$s is achtergebleven + Laatst gehoord waar je %1$s was. Tik om de plek te zien. \ 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 8beaabfa..8a14a23b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -338,4 +338,14 @@ Последний сигнал Сигнал только что + Продолжать приём в фоне + Принимает сигналы меток даже при закрытом приложении и записывает, где они были услышаны. Требует постоянного уведомления и расходует больше заряда. По умолчанию выключено. + Фоновый приём + Отображается, пока приложение принимает сигналы меток в фоне. + Приём сигналов меток + Нажмите, чтобы открыть карту. + Забыта + Предупреждает, когда метка перестала быть слышна, а вы от неё удалились. + %1$s осталась на месте + Последний сигнал там, где вы были %1$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 96c820d6..53aa4ece 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -338,4 +338,14 @@ 最后收到 信号强度 刚刚 + 在后台持续接收 + 即使应用已关闭也会接收标签,并记录听到的位置。需要常驻通知,耗电更多。默认关闭。 + 后台接收 + 应用在后台接收标签时显示。 + 正在接收标签 + 点按以打开地图。 + 遗落提醒 + 当标签不再被接收且你已离开时提醒你。 + %1$s 被落下了 + 在你%1$s所在的位置最后一次接收到。点按查看地点。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index cf899749..b03e2bef 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -338,4 +338,14 @@ 最後收到 訊號強度 剛剛 + 在背景持續接收 + 即使應用程式已關閉也會接收標籤,並記錄聽到的位置。需要常駐通知,較耗電。預設關閉。 + 背景接收 + 應用程式在背景接收標籤時顯示。 + 正在接收標籤 + 輕觸以開啟地圖。 + 遺留提醒 + 當標籤不再被接收且你已離開時提醒你。 + %1$s 被留下了 + 在你%1$s所在的位置最後一次接收到。輕觸查看地點。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c1628654..d39d7b72 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -371,4 +371,14 @@ You can set this up now, or any time later from Settings. Last seen Signal just now + Keep listening in the background + Listens for your tags even when the app is closed, and records where they were heard. Needs a permanent notification, and uses more battery. Off by default. + Background scanning + Shown while the app is listening for your tags in the background. + Listening for your tags + Tap to open the map. + Left behind + Alerts you when a tag stops being heard and you have moved away from it. + %1$s stayed behind + Last heard where you were %1$s. Tap to see the place. 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 aea19427..3ce50b4d 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 @@ -75,7 +75,7 @@ void awaitThenSettle() throws InterruptedException { private static NearbyTagWatcher watcherWith( final NearbyTagWatcher.SightingListener listener, final long[] clockMs) { return new NearbyTagWatcher( - anyResolver(), listener, new NearbyTagIndex(), () -> clockMs[0]); + anyResolver(), listener, 0, new NearbyTagIndex(), () -> clockMs[0]); } @Test @@ -121,7 +121,7 @@ public void callsAgainOnceTheThrottleWindowHasPassed() throws InterruptedExcepti @Test public void aNullListenerIsSimplySkipped() { final NearbyTagWatcher watcher = new NearbyTagWatcher( - anyResolver(), null, new NearbyTagIndex(), () -> 0L); + anyResolver(), null, 0, new NearbyTagIndex(), () -> 0L); // Must not throw. watcher.maybeNotifySightingListener(sightingOf(BEACON_ID), MAC); diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/LeftBehindTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/LeftBehindTest.java new file mode 100644 index 00000000..28703c81 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/LeftBehindTest.java @@ -0,0 +1,76 @@ +package dev.wander.android.opentagviewer.util; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * The rule behind "you are leaving without your keys". + * + *

An alert that fires when it should not is worse than none: people turn off a feature that + * cries wolf, and then it is not there on the day it matters. So the rule is deliberately hard + * to satisfy - the tag must have gone quiet and the phone must have gone somewhere else. + */ +public class LeftBehindTest { + + /** The cafe. */ + 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 long WELL_PAST = NOON + LeftBehind.QUIET_FOR_MS + 1; + + /** Roughly 220 metres north, which is past the threshold. */ + private static final double FAR_LAT = LAT + 0.002; + + @Test + public void quietAndFarAwayIsLeftBehind() { + assertTrue(LeftBehind.looksLeftBehind(NOON, LAT, LON, WELL_PAST, FAR_LAT, LON)); + } + + /** + * Silence alone is not enough, and this is the case that matters. A tag in a pocket + * with a body between it and the phone misses scan windows, and the background scan uses the + * cheapest mode the platform offers. Alerting on that would fire on every walk. + */ + @Test + public void quietButStillInTheSamePlaceIsNotLeftBehind() { + assertFalse(LeftBehind.looksLeftBehind(NOON, LAT, LON, WELL_PAST, LAT, LON)); + } + + /** Moving away while the tag is still being heard means it came along. */ + @Test + public void farAwayButHeardRecentlyIsNotLeftBehind() { + assertFalse(LeftBehind.looksLeftBehind( + NOON, LAT, LON, NOON + LeftBehind.QUIET_FOR_MS - 1, FAR_LAT, LON)); + } + + @Test + public void aTagThisPhoneHasNeverHeardIsNotLeftBehind() { + assertFalse(LeftBehind.looksLeftBehind(null, null, null, WELL_PAST, FAR_LAT, LON)); + } + + /** + * Without a position for the last sighting there is no way to tell moving away from standing + * still, and silence on its own does not earn an alert. + */ + @Test + public void withoutAPositionForTheLastSightingNothingIsClaimed() { + assertFalse(LeftBehind.looksLeftBehind(NOON, null, null, WELL_PAST, FAR_LAT, LON)); + } + + /** A few metres of GPS wobble is not going somewhere else. */ + @Test + public void gpsWobbleIsNotMovingAway() { + assertFalse(LeftBehind.looksLeftBehind( + NOON, LAT, LON, WELL_PAST, LAT + 0.00005, LON)); + } + + /** The threshold is well past Bluetooth range, so it cannot fire from the same room. */ + @Test + public void theDistanceThresholdIsWellPastBluetoothRange() { + assertTrue("a tag would still be audible at this range", + LeftBehind.MOVED_AWAY_METRES > 50.0); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/android/CachedPhoneLocationTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/android/CachedPhoneLocationTest.java new file mode 100644 index 00000000..c6848ea5 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/android/CachedPhoneLocationTest.java @@ -0,0 +1,110 @@ +package dev.wander.android.opentagviewer.util.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * The cache that stops the location indicator blinking on every sighting. + * + *

Android lights the indicator whenever an app touches location, and the sighting path + * touches it per sighting, per tag, twice - once to place the sighting and once for the + * left-behind rule. With the background service running that is a chip flashing every few + * seconds, which reads as the app tracking somebody far harder than it does. + */ +public class CachedPhoneLocationTest { + + private static final double LAT = 49.4767; + private static final double LON = 8.5622; + + /** Counts reads, so "how often did this touch location" is the thing being asserted. */ + private static final class CountingLocation implements PhoneLocation { + private final AtomicInteger reads = new AtomicInteger(); + private Fix answer; + + private CountingLocation(final Fix answer) { + this.answer = answer; + } + + @Override + public Fix lastKnown() { + this.reads.incrementAndGet(); + return this.answer; + } + } + + @Test + public void repeatedCallsTouchLocationOnce() { + final CountingLocation real = new CountingLocation(new PhoneLocation.Fix(LAT, LON, 8)); + final long[] clock = {1_000L}; + final CachedPhoneLocation cached = new CachedPhoneLocation(real, () -> clock[0]); + + cached.lastKnown(); + cached.lastKnown(); + cached.lastKnown(); + + assertEquals("three sightings must not be three location accesses", 1, real.reads.get()); + } + + @Test + public void theCacheExpires() { + final CountingLocation real = new CountingLocation(new PhoneLocation.Fix(LAT, LON, 8)); + final long[] clock = {1_000L}; + final CachedPhoneLocation cached = new CachedPhoneLocation(real, () -> clock[0]); + + cached.lastKnown(); + clock[0] += CachedPhoneLocation.FRESH_FOR_MS; + cached.lastKnown(); + + assertEquals(2, real.reads.get()); + } + + /** + * The correction that makes caching honest. A minute-old position handed back at the + * accuracy of a fresh fix would be drawn on the map as a tight circle around somewhere the + * phone no longer is. Walking pace times the age is the width it can still claim. + */ + @Test + public void aCachedFixReportsTheAccuracyItCanStillClaim() { + final CountingLocation real = new CountingLocation(new PhoneLocation.Fix(LAT, LON, 8)); + final long[] clock = {1_000L}; + final CachedPhoneLocation cached = new CachedPhoneLocation(real, () -> clock[0]); + + cached.lastKnown(); + clock[0] += 30_000L; + final PhoneLocation.Fix stale = cached.lastKnown(); + + assertEquals("the position itself does not move", LAT, stale.getLatitude(), 0.000001); + assertTrue("thirty seconds of walking is tens of metres, and must be admitted", + stale.getAccuracyMetres() > 8 + 30); + } + + @Test + public void aFreshFixIsNotWidened() { + final CountingLocation real = new CountingLocation(new PhoneLocation.Fix(LAT, LON, 8)); + final long[] clock = {1_000L}; + + assertEquals(8, new CachedPhoneLocation(real, () -> clock[0]).lastKnown() + .getAccuracyMetres()); + } + + /** + * Having no fix is a state that lasts, so retrying it per sighting would light the indicator + * exactly as often as succeeding, for an answer that will not have changed. + */ + @Test + public void havingNoFixIsRememberedToo() { + final CountingLocation real = new CountingLocation(null); + final long[] clock = {1_000L}; + final CachedPhoneLocation cached = new CachedPhoneLocation(real, () -> clock[0]); + + assertNull(cached.lastKnown()); + assertNull(cached.lastKnown()); + + assertEquals(1, real.reads.get()); + } +} From 637314b56fba778195a5a9183280d57499ed3e7d Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:55:50 +0200 Subject: [PATCH 39/61] Let the user switch off the alert per tag, and make the alert loud Three things, all about the left-behind alert being useful rather than merely correct. A per-tag switch on the device info screen. Some tags are meant to be left behind: the spare key at home, the bike lock in the cellar. Without a way to say so the feature is a source of false alarms and the user turns the whole thing off. The column is a nullable Boolean and null means on, so every existing tag keeps alerting without a migration having to decide anything. The alert goes out on the alarm stream, not the notification one. A chime at notification volume is often nothing at all for a phone in a pocket, and the alert is only worth having in the half minute while walking back is still easy. The channel id carries a suffix because a notification channel is immutable once created: republishing the same id with new sound settings is silently ignored, so changing how it sounds means a new channel. The scan mode now differs by caller. A screen is somebody actively looking for a tag right now, so it scans at SCAN_MODE_LOW_LATENCY. The background service runs all day and takes SCAN_MODE_BALANCED, a quarter of the radio time. Both are a step up from the low-power mode the service started on, which left multi-second gaps and made the alert slower than the situation it is meant to catch. The throwaway release keystore is gitignored on the way past. It is a local signing key for build testing and has no business in the repository. --- .gitignore | 3 + .../9.json | 511 ++++++++++++++++++ .../opentagviewer/DeviceInfoActivity.java | 42 +- .../android/opentagviewer/MapsActivity.java | 4 +- .../opentagviewer/ble/NearbyTagWatcher.java | 14 +- .../db/repo/BeaconRepository.java | 40 ++ .../db/room/OpenTagViewerDatabase.java | 20 +- .../db/room/dao/UserBeaconOptionsDao.java | 21 +- .../db/room/entity/UserBeaconOptions.java | 18 + .../service/NearbyScanService.java | 54 +- .../main/res/layout/activity_device_info.xml | 54 ++ app/src/main/res/values-de/strings.xml | 2 + 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 + app/src/main/res/values/strings.xml | 2 + 21 files changed, 789 insertions(+), 12 deletions(-) create mode 100644 app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/9.json diff --git a/.gitignore b/.gitignore index 45fdaa9c..ae46c51b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ tmp/ # stripped: device names ("'s iPad"), record UUIDs, key material. Committing it would # publish, in a notebook, exactly what was redacted out of the plists next to it. scripts/plist_explorer.ipynb + +# Local throwaway signing key for release builds. Never a real one. +*.jks diff --git a/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/9.json b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/9.json new file mode 100644 index 00000000..05c246ba --- /dev/null +++ b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/9.json @@ -0,0 +1,511 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "2baa276e969882045c005d4144417dbe", + "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, `alert_on_separation` 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 + }, + { + "fieldPath": "alertOnSeparation", + "columnName": "alert_on_separation", + "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, '2baa276e969882045c005d4144417dbe')" + ] + } +} \ No newline at end of file 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 aea1fe30..ae1a9b91 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java @@ -10,6 +10,7 @@ import android.content.ClipData; import android.content.ClipboardManager; +import android.bluetooth.le.ScanSettings; import android.content.Context; import android.content.Intent; import android.net.Uri; @@ -40,6 +41,7 @@ import androidx.emoji2.emojipicker.EmojiViewItem; import com.google.android.material.button.MaterialButton; +import com.google.android.material.materialswitch.MaterialSwitch; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.textfield.TextInputEditText; @@ -683,7 +685,8 @@ private void startWatchingForThisTag() { } this.nearbyWatchDisposable = new NearbyTagWatcher( - AppDependencies.accessoryMacResolver(), this.sightingPersister::onSighting) + AppDependencies.accessoryMacResolver(), this.sightingPersister::onSighting, + ScanSettings.SCAN_MODE_LOW_LATENCY) .watch(this.getApplicationContext(), Map.of(this.beaconId, accessoryJson)) .observeOn(AndroidSchedulers.mainThread()) .subscribe( @@ -845,6 +848,43 @@ private void showStatusByteForDebugging(final int statusByte) { this.findViewById(R.id.settings_debug_ble_status_byte).setVisibility(VISIBLE); } + /** + * Shows and wires the per-tag left-behind switch. + * + *

Undecided reads as on, so a tag nobody has answered for still raises the alarm - see + * {@code UserBeaconOptions.alertOnSeparation}. The switch is only revealed once the answer + * has been read, so it cannot flick from a default to the stored value in front of somebody. + */ + private void showLeftBehindSwitch() { + if (this.leftBehindLookup != null && !this.leftBehindLookup.isDisposed()) { + this.leftBehindLookup.dispose(); + } + + this.leftBehindLookup = this.beaconRepo.getAlertOnSeparation(this.beaconId) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(warn -> { + this.binding.setWarnIfLeftBehind(warn); + + final MaterialSwitch toggle = this.findViewById(R.id.device_warn_left_behind); + toggle.setChecked(warn); + toggle.setOnCheckedChangeListener((button, isChecked) -> + this.beaconRepo.storeAlertOnSeparation(this.beaconId, isChecked) + .subscribe(() -> Log.i(TAG, "Left-behind alerts for beaconId=" + + this.beaconId + " are now " + + (isChecked ? "on" : "off")), + error -> Log.w(TAG, + "Could not store the left-behind choice", + error))); + + this.findViewById(R.id.device_warn_left_behind_row).setVisibility(VISIBLE); + this.findViewById(R.id.device_warn_left_behind_explainer) + .setVisibility(VISIBLE); + }, error -> Log.w(TAG, "Could not read the left-behind choice", error)); + } + + /** The in-flight read of the per-tag left-behind choice. */ + private Disposable leftBehindLookup; + /** * Writes the "Last seen" row, e.g. "3 minutes ago", from a wall-clock timestamp. * 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 8afc6dba..69242df9 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -19,6 +19,7 @@ import androidx.core.view.WindowCompat; +import android.bluetooth.le.ScanSettings; import android.content.Intent; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; @@ -707,7 +708,8 @@ private void startWatchingForNearbyTags() { } this.nearbyWatchDisposable = new NearbyTagWatcher( - AppDependencies.accessoryMacResolver(), this.sightingPersister::onSighting) + AppDependencies.accessoryMacResolver(), this.sightingPersister::onSighting, + ScanSettings.SCAN_MODE_LOW_LATENCY) .watch(this.getApplicationContext(), accessoryJsonByBeaconId) .observeOn(AndroidSchedulers.mainThread()) .subscribe( 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 9f11b597..b940b3eb 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 @@ -34,10 +34,18 @@ * continuously when the user turns background scanning on, which is a recording feature and is * why it is opt-in and carries a permanent notification. * - *

The difference reaches this class as {@link #scanMode}: a screen wants results promptly, - * a service running all day wants the cheapest duty cycle the platform offers. + *

The difference reaches this class as {@link #scanMode}, and the two callers sit at + * opposite ends of it. A screen is open because somebody is looking for a tag right now, so it + * scans at {@code SCAN_MODE_LOW_LATENCY} - the radio listening continuously, which is what makes + * the signal meter move as you walk toward something. That is affordable precisely because a + * screen is open for minutes, not days. * - *

The screen's {@code SCAN_MODE_BALANCED} - a middle ground between the low-latency mode + *

The service takes {@code SCAN_MODE_BALANCED} instead: a quarter of the radio time, running + * all day. Cheaper still exists, and was tried - low power left gaps of over a minute with a tag + * in a pocket, which the left-behind rule then has to see through, and every gap it cannot costs + * a full-power verification burst of its own. + * + *

{@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 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 9c9fcfb5..503b8a9b 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 @@ -701,6 +701,46 @@ public Observable recordLocalSighting( */ public static final String LOCAL_REPORT_DESCRIPTION = "Heard over Bluetooth"; + /** + * The tags whose owner does not want to be warned when they are left behind. + * + *

Returned as the exceptions rather than the permissions because null - nobody has + * decided - means yes. Somebody who turned background scanning on wants to be told; the + * switch exists for the tag that is meant to stay behind. + */ + public Observable> getBeaconsWithAlertsOff() { + return Observable.fromCallable(() -> { + final Set off = new HashSet<>(); + + for (final UserBeaconOptions options : db.userBeaconOptionsDao().getAll()) { + if (options.alertOnSeparation != null && !options.alertOnSeparation) { + off.add(options.beaconId); + } + } + + return off; + }).subscribeOn(Schedulers.io()); + } + + /** Store whether being left behind is worth a noise for this tag. */ + public Completable storeAlertOnSeparation(final String beaconId, final boolean alert) { + return Completable.fromRunnable(() -> + db.userBeaconOptionsDao().storeAlertOnSeparation( + beaconId, alert, System.currentTimeMillis())) + .subscribeOn(Schedulers.io()); + } + + /** + * Whether being left behind is worth a noise for this tag. Null - undecided - reads as yes. + */ + public Observable getAlertOnSeparation(final String beaconId) { + return Observable.fromCallable(() -> { + final UserBeaconOptions options = db.userBeaconOptionsDao().getById(beaconId); + return options == null || options.alertOnSeparation == null + || options.alertOnSeparation; + }).subscribeOn(Schedulers.io()); + } + /** * The key material for every tag worth listening for, keyed by beacon. * 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 7387a80b..78d27bdf 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 @@ -34,7 +34,7 @@ UserBeaconOptions.class, LastBleSighting.class }, - version = 8 + version = 9 ) public abstract class OpenTagViewerDatabase extends RoomDatabase { private static OpenTagViewerDatabase INSTANCE = null; @@ -180,6 +180,21 @@ public void migrate(@NonNull SupportSQLiteDatabase db) { } }; + /** + * v8 → v9: adds {@code alert_on_separation} to {@code UserBeaconOptions}, the per-tag answer + * to whether being left behind is worth a noise. + * + *

Additive and nullable rather than defaulted, and null reads as yes - see + * {@link UserBeaconOptions#alertOnSeparation}. Every existing row is null, which is correct: + * nobody has turned an alert off for a tag that could not alert yet. + */ + public static final Migration MIGRATION_8_9 = new Migration(8, 9) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE UserBeaconOptions ADD COLUMN alert_on_separation INTEGER"); + } + }; + /** * The database file's name, which is also read directly - see * {@code OpenAirTagApplication.isFirstRun()}, which uses the file's presence to tell a new @@ -196,7 +211,8 @@ 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_6_7, MIGRATION_7_8) + MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, + MIGRATION_8_9) .build(); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/UserBeaconOptionsDao.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/UserBeaconOptionsDao.java index cf57df13..df7b231a 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/UserBeaconOptionsDao.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/UserBeaconOptionsDao.java @@ -78,9 +78,28 @@ default void storeArrangement(final Map positions, final long n } } + /** + * Store whether this tag is worth a noise when it is left behind. + * + *

Insert-then-update rather than a replace, for the reason spelled out on + * {@link #storeArrangement}: most tags have no row here, and {@code INSERT OR REPLACE} would + * delete the nickname and the arrangement on the way past. + */ + @Transaction + default void storeAlertOnSeparation( + final String beaconId, final boolean alert, final long now) { + + this.createIfAbsent(beaconId, now); + this.setAlertOnSeparation(beaconId, alert, now); + } + + @Query("UPDATE UserBeaconOptions SET alert_on_separation = :alert, last_update = :now" + + " WHERE beacon_id = :beaconId") + void setAlertOnSeparation(String beaconId, boolean alert, long now); + /** A row to hang a position on, for a tag the user has never renamed. See above. */ @Query("INSERT OR IGNORE INTO UserBeaconOptions (beacon_id, last_update, ui_name, ui_emoji," - + " ui_order) VALUES (:beaconId, :now, NULL, NULL, NULL)") + + " ui_order, alert_on_separation) VALUES (:beaconId, :now, NULL, NULL, NULL, NULL)") void createIfAbsent(String beaconId, long now); /** Writes only the position, leaving the nickname and emoji exactly as they are. */ diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/UserBeaconOptions.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/UserBeaconOptions.java index 5b36f0bb..6d9337d9 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/UserBeaconOptions.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/UserBeaconOptions.java @@ -54,4 +54,22 @@ public class UserBeaconOptions { */ @ColumnInfo(name = "ui_order") public Integer uiOrder; + + /** + * Whether to warn when this tag is left behind, or null if the user has not decided. + * + *

Null means yes. Turning background scanning on is already a deliberate act, and + * somebody who did it wants to be told - a feature that alerts for nothing until each tag is + * enabled separately looks broken on the day it is set up. + * + *

Per tag because the answer genuinely differs per tag. Keys and a wallet are worth a + * noise; a tag that lives in a car, or on something that is meant to stay behind, would + * alert every time its owner walks into the house. One switch for all of them would be + * turned off by the first tag that cried wolf, taking the useful ones with it. + * + *

Here rather than on {@code OwnedBeacons} for the reason this whole table exists: it is + * the user's decision, and an account refresh must not touch it. + */ + @ColumnInfo(name = "alert_on_separation") + public Boolean alertOnSeparation; } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java b/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java index 7cd27e51..bec286cb 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java @@ -8,6 +8,8 @@ import android.content.Context; import android.content.Intent; import android.content.pm.ServiceInfo; +import android.media.AudioAttributes; +import android.media.RingtoneManager; import android.os.Build; import android.os.IBinder; import android.util.Log; @@ -16,6 +18,7 @@ import androidx.core.app.NotificationCompat; import java.util.Map; +import java.util.Set; import dev.wander.android.opentagviewer.MapsActivity; import dev.wander.android.opentagviewer.R; @@ -93,8 +96,15 @@ public class NearbyScanService extends Service { */ private static final String ACTION_DISMISSED = "dev.wander.opentagviewer.SCAN_DISMISSED"; - /** Channel for the left-behind alert, which is loud on purpose - see {@link #alertLeftBehind}. */ - private static final String ALERT_CHANNEL_ID = "tag_left_behind"; + /** + * Channel for the left-behind alert, which is loud on purpose - see {@link #alertLeftBehind}. + * + *

The suffix is not decoration. A notification channel is immutable once created: + * changing the sound or the vibration in code does nothing for anybody who already has the + * old one, and there is no way to update it. The only way to change how an alert sounds is + * to publish a new channel, so the id carries a version. + */ + private static final String ALERT_CHANNEL_ID = "tag_left_behind_alarm"; /** * How often the left-behind rule is evaluated. @@ -143,6 +153,15 @@ private Presence(final long lastHeardMs, final Double latitude, final Double lon * them which, which is most of the message gone. */ private Map namesByBeaconId = Map.of(); + + /** + * The tags their owner has told us not to warn about, read when the watch starts. + * + *

Held as the exceptions because undecided means yes - see + * {@code UserBeaconOptions.alertOnSeparation}. They are still scanned for and still recorded; + * only the noise is off. + */ + private Set alertsOff = Set.of(); private AccessorySightingPersister sightingPersister; private PhoneLocation phoneLocation; @@ -236,6 +255,7 @@ private void startWatching() { .subscribeOn(Schedulers.io()) .subscribe(beacons -> { this.namesByBeaconId = readNames(beacons); + this.alertsOff = this.beaconRepo.getBeaconsWithAlertsOff().blockingFirst(); this.watchThese(keyMaterialOf(beacons)); }, error -> Log.w(TAG, "Could not read the tags to watch for", error)); } @@ -375,6 +395,13 @@ private void checkForLeftBehind() { // the only other moment the position is worth reading. known.gone = true; + if (this.alertsOff.contains(entry.getKey())) { + // Still scanned for, still recorded - the owner has only said this one is not + // worth waking them for. Skipping the verification scan too, since nothing would + // be done with the answer. + continue; + } + // **A missing position must not swallow the alert.** It used to, left over from when // distance was half the rule; the verification scan decides now, and "your keys are // not with you" is worth saying whether or not the phone can say where. It also @@ -497,7 +524,23 @@ private void alertLeftBehind(final String beaconId, final long lastHeardMs) { this.getString(R.string.left_behind_channel), NotificationManager.IMPORTANCE_HIGH); channel.setDescription(this.getString(R.string.left_behind_channel_description)); + + // **The alarm stream, not the notification one.** A notification chime is meant to + // be ignorable, and it plays at whatever the notification volume happens to be - + // which for a phone in a pocket is often nothing. This alert is only useful in the + // half minute while walking back is still easy, so it goes out the way an alarm + // clock does: alarm usage, alarm volume, and audible with the ringer down. + channel.setSound( + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM), + new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build()); + + // Long enough to be felt through a coat, and unlike a message buzz. channel.enableVibration(true); + channel.setVibrationPattern(new long[] {0, 500, 250, 500, 250, 800}); + manager.createNotificationChannel(channel); } @@ -516,8 +559,11 @@ private void alertLeftBehind(final String beaconId, final long lastHeardMs) { this.namesByBeaconId.getOrDefault(beaconId, beaconId))) .setContentText(this.getString(R.string.left_behind_text, howLongAgo)) .setSmallIcon(R.drawable.ic_launcher_monochrome) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_REMINDER) + .setPriority(NotificationCompat.PRIORITY_MAX) + // An alarm rather than a reminder: it says to the system, and to anything + // summarising notifications, that this is time-critical rather than something + // to read later. + .setCategory(NotificationCompat.CATEGORY_ALARM) .setContentIntent(show) .setAutoCancel(true) .build(); diff --git a/app/src/main/res/layout/activity_device_info.xml b/app/src/main/res/layout/activity_device_info.xml index 067edb84..19279809 100644 --- a/app/src/main/res/layout/activity_device_info.xml +++ b/app/src/main/res/layout/activity_device_info.xml @@ -129,6 +129,11 @@ name="bleStatusByte" type="String" /> + + + @@ -520,6 +525,55 @@ the only battery figure that will ever exist. Stays visible with the "Last seen" row above it carrying its age. --> + + + + + + + + Meldet sich, wenn ein Tag nicht mehr zu hören ist und du dich davon entfernt hast. %1$s ist zurückgeblieben Zuletzt dort gehört, wo du %1$s warst. Zum Anzeigen tippen. + Warnen, wenn zurückgelassen + Schlägt Alarm, wenn dieses Tag nicht mehr zu hören ist und du weitergegangen bist. Setzt den Hintergrund-Empfang voraus. \ 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 ed244d26..4a2b97b1 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -348,4 +348,6 @@ You can set this up now, or any time later from Settings. Alerts you when a tag stops being heard and you have moved away from it. %1$s stayed behind Last heard where you were %1$s. Tap to see the place. + Warn if left behind + Sounds an alarm when this tag stops being heard and you have moved on. Needs background listening to be on. \ 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 0d30bada..c4b3b64d 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -348,4 +348,6 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.< Vous alerte quand une balise n\'est plus entendue et que vous vous en êtes éloigné. %1$s est resté sur place Entendue pour la dernière fois là où vous étiez %1$s. Touchez pour voir l\'endroit. + Avertir si oublié + Déclenche une alarme quand cette balise n\'est plus entendue et que vous êtes parti. Nécessite l\'écoute en arrière-plan. \ 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 b66c87f6..5ba7bdd1 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -348,4 +348,6 @@ タグが受信できなくなり、その場所から離れたときに知らせます。 %1$s が置き去りです %1$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 e165a261..8a542e21 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -348,4 +348,6 @@ 태그가 더 이상 수신되지 않고 그 자리에서 멀어졌을 때 알립니다. %1$s을(를) 두고 왔습니다 %1$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 68f7cad1..34922aa3 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -348,4 +348,6 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen. Waarschuwt je als een tag niet meer te horen is en je ervandaan bent gelopen. %1$s is achtergebleven Laatst gehoord waar je %1$s was. Tik om de plek te zien. + Waarschuwen bij achterlaten + Slaat alarm als deze tag niet meer te horen is en je verder bent gelopen. Vereist luisteren op de achtergrond. \ 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 8a14a23b..cbe787b9 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -348,4 +348,6 @@ Предупреждает, когда метка перестала быть слышна, а вы от неё удалились. %1$s осталась на месте Последний сигнал там, где вы были %1$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 53aa4ece..3d1cf4ba 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -348,4 +348,6 @@ 当标签不再被接收且你已离开时提醒你。 %1$s 被落下了 在你%1$s所在的位置最后一次接收到。点按查看地点。 + 遗落时提醒 + 当此标签不再被接收且你已离开时发出提示音。需要开启后台接收。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index b03e2bef..4408bd95 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -348,4 +348,6 @@ 當標籤不再被接收且你已離開時提醒你。 %1$s 被留下了 在你%1$s所在的位置最後一次接收到。輕觸查看地點。 + 遺留時提醒 + 當此標籤不再被接收且你已離開時發出提示音。需要開啟背景接收。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d39d7b72..0a5096bb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -381,4 +381,6 @@ You can set this up now, or any time later from Settings. Alerts you when a tag stops being heard and you have moved away from it. %1$s stayed behind Last heard where you were %1$s. Tap to see the place. + Warn if left behind + Sounds an alarm when this tag stops being heard and you have moved on. Needs background listening to be on. From 1471ecc4c11cb3afbcc67e28dc931234864c87fe Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:23:32 +0200 Subject: [PATCH 40/61] Show the left-behind switch at all, and default it to off The switch was unreachable. Its row is `gone` in the layout and `showLeftBehindSwitch` is what reveals it, but nothing ever called that method: it was written, compiled, shipped and never run. javac says nothing about an uncalled private method, the build was green, and the feature was verified by reading the code rather than by opening the screen. It is now called from onResume, which also re-reads the stored answer when coming back to the screen, and the lookup is disposed in onPause. Undecided now reads as off rather than on. Most tags a person owns are put down on purpose somewhere: a spare key in a drawer, a tag in a car, one on a bag that lives in the hall. Alerting for all of them until each is switched off individually is a stream of false alarms, and what people switch off after the second one is the whole feature rather than the one tag. So a tag has to be asked for by name and the switch is the asking. That inverts the set the service holds: getBeaconsWithAlertsOn returns the permissions instead of the exceptions, and only an explicit true counts, so a tag with no options row at all stays silent. The migration is unchanged, and null still means undecided; only what undecided means has moved. Verified on a device this time: the switch renders, is enabled, and reads checked="false" on a tag nobody has answered for. --- .../opentagviewer/DeviceInfoActivity.java | 6 ++++- .../db/repo/BeaconRepository.java | 27 ++++++++++--------- .../db/room/OpenTagViewerDatabase.java | 4 +-- .../db/room/entity/UserBeaconOptions.java | 8 +++--- .../service/NearbyScanService.java | 20 +++++++------- 5 files changed, 37 insertions(+), 28 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 ae1a9b91..2a536e58 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java @@ -661,12 +661,16 @@ protected void onResume() { super.onResume(); this.startWatchingForThisTag(); this.showWhatWasHeardOverBluetooth(); + this.showLeftBehindSwitch(); } @Override protected void onPause() { super.onPause(); this.stopWatchingForThisTag(); + if (this.leftBehindLookup != null && !this.leftBehindLookup.isDisposed()) { + this.leftBehindLookup.dispose(); + } } /** @@ -851,7 +855,7 @@ private void showStatusByteForDebugging(final int statusByte) { /** * Shows and wires the per-tag left-behind switch. * - *

Undecided reads as on, so a tag nobody has answered for still raises the alarm - see + *

Undecided reads as off, so a tag nobody has answered for stays silent - see * {@code UserBeaconOptions.alertOnSeparation}. The switch is only revealed once the answer * has been read, so it cannot flick from a default to the stored value in front of somebody. */ 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 503b8a9b..cdf8eaf8 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 @@ -702,23 +702,27 @@ public Observable recordLocalSighting( public static final String LOCAL_REPORT_DESCRIPTION = "Heard over Bluetooth"; /** - * The tags whose owner does not want to be warned when they are left behind. + * The tags whose owner wants to be warned when they are left behind. * - *

Returned as the exceptions rather than the permissions because null - nobody has - * decided - means yes. Somebody who turned background scanning on wants to be told; the - * switch exists for the tag that is meant to stay behind. + *

Returned as the permissions rather than the exceptions because null - nobody has + * decided - means no. A tag has to be asked for by name: most tags a person owns are + * routinely left somewhere on purpose, and a feature that alarms about all of them until + * told otherwise gets switched off wholesale after the second false alarm. + * + *

Only an explicit true counts, so a tag with no options row at all is silent - which is + * every tag until somebody flips the switch. */ - public Observable> getBeaconsWithAlertsOff() { + public Observable> getBeaconsWithAlertsOn() { return Observable.fromCallable(() -> { - final Set off = new HashSet<>(); + final Set on = new HashSet<>(); for (final UserBeaconOptions options : db.userBeaconOptionsDao().getAll()) { - if (options.alertOnSeparation != null && !options.alertOnSeparation) { - off.add(options.beaconId); + if (Boolean.TRUE.equals(options.alertOnSeparation)) { + on.add(options.beaconId); } } - return off; + return on; }).subscribeOn(Schedulers.io()); } @@ -731,13 +735,12 @@ public Completable storeAlertOnSeparation(final String beaconId, final boolean a } /** - * Whether being left behind is worth a noise for this tag. Null - undecided - reads as yes. + * Whether being left behind is worth a noise for this tag. Null - undecided - reads as no. */ public Observable getAlertOnSeparation(final String beaconId) { return Observable.fromCallable(() -> { final UserBeaconOptions options = db.userBeaconOptionsDao().getById(beaconId); - return options == null || options.alertOnSeparation == null - || options.alertOnSeparation; + return options != null && Boolean.TRUE.equals(options.alertOnSeparation); }).subscribeOn(Schedulers.io()); } 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 78d27bdf..2184dc66 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 @@ -184,9 +184,9 @@ public void migrate(@NonNull SupportSQLiteDatabase db) { * v8 → v9: adds {@code alert_on_separation} to {@code UserBeaconOptions}, the per-tag answer * to whether being left behind is worth a noise. * - *

Additive and nullable rather than defaulted, and null reads as yes - see + *

Additive and nullable rather than defaulted, and null reads as no - see * {@link UserBeaconOptions#alertOnSeparation}. Every existing row is null, which is correct: - * nobody has turned an alert off for a tag that could not alert yet. + * nobody has asked for an alert on a tag that could not alert yet. */ public static final Migration MIGRATION_8_9 = new Migration(8, 9) { @Override diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/UserBeaconOptions.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/UserBeaconOptions.java index 6d9337d9..d779acc3 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/UserBeaconOptions.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/UserBeaconOptions.java @@ -58,9 +58,11 @@ public class UserBeaconOptions { /** * Whether to warn when this tag is left behind, or null if the user has not decided. * - *

Null means yes. Turning background scanning on is already a deliberate act, and - * somebody who did it wants to be told - a feature that alerts for nothing until each tag is - * enabled separately looks broken on the day it is set up. + *

Null means no. Most tags a person owns are routinely put down on purpose: the + * spare key in a drawer, the tag in a car, the one on a bag that lives in the hall. An alert + * that fires for all of them until each is switched off individually is a stream of false + * alarms, and the thing people switch off after the second one is the whole feature. So a + * tag has to be asked for by name, and the switch is the asking. * *

Per tag because the answer genuinely differs per tag. Keys and a wallet are worth a * noise; a tag that lives in a car, or on something that is meant to stay behind, would diff --git a/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java b/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java index bec286cb..aaa81e51 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java @@ -155,13 +155,13 @@ private Presence(final long lastHeardMs, final Double latitude, final Double lon private Map namesByBeaconId = Map.of(); /** - * The tags their owner has told us not to warn about, read when the watch starts. + * The tags their owner has asked to be warned about, read when the watch starts. * - *

Held as the exceptions because undecided means yes - see - * {@code UserBeaconOptions.alertOnSeparation}. They are still scanned for and still recorded; - * only the noise is off. + *

Held as the permissions because undecided means no - see + * {@code UserBeaconOptions.alertOnSeparation}. Every other tag is still scanned for and still + * recorded; only the noise is off, and it stays off until somebody asks for it by name. */ - private Set alertsOff = Set.of(); + private Set alertsOn = Set.of(); private AccessorySightingPersister sightingPersister; private PhoneLocation phoneLocation; @@ -255,7 +255,7 @@ private void startWatching() { .subscribeOn(Schedulers.io()) .subscribe(beacons -> { this.namesByBeaconId = readNames(beacons); - this.alertsOff = this.beaconRepo.getBeaconsWithAlertsOff().blockingFirst(); + this.alertsOn = this.beaconRepo.getBeaconsWithAlertsOn().blockingFirst(); this.watchThese(keyMaterialOf(beacons)); }, error -> Log.w(TAG, "Could not read the tags to watch for", error)); } @@ -395,10 +395,10 @@ private void checkForLeftBehind() { // the only other moment the position is worth reading. known.gone = true; - if (this.alertsOff.contains(entry.getKey())) { - // Still scanned for, still recorded - the owner has only said this one is not - // worth waking them for. Skipping the verification scan too, since nothing would - // be done with the answer. + if (!this.alertsOn.contains(entry.getKey())) { + // Still scanned for, still recorded - the owner has just not asked to be woken + // for this one, which is the default. Skipping the verification scan too, since + // nothing would be done with the answer. continue; } From 06d62aacaa270e78f173db4b7eaa2a7a40f428ab Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:31:06 +0200 Subject: [PATCH 41/61] Re-read which tags want an alert, instead of once at startup Turning the switch on did nothing. The service read the set of tags whose owner wants a left-behind alert exactly once, inside the getAllBeacons subscribe that starts the watch, and never again. The switch lives in the app and the service outlives it, so that set is a snapshot of what the user wanted before they went to change it. Flip the switch on a running service and the answer stayed whatever it was at startup, which for a fresh install with the new off-by-default is the empty set. From the outside that is indistinguishable from the feature not working. It is now re-read at the top of each check pass: one small query against UserBeaconOptions every fifteen seconds, on the IO scheduler the check already runs on. Cheap enough to do unconditionally rather than trying to guess when it might have moved. A failed read keeps the previous answer, because stale permissions are still better than none. Logged when the set changes, not every tick. That line is the only outward sign that the switch reached the service at all, which is precisely what was invisible while it did not. Verified on a device: with one tag switched on, the service logs "Left-behind alerts are now wanted for 1 tag(s)" within a tick of starting. The alert firing itself is not covered by that - it still needs a tag to actually go quiet for thirty seconds. --- .../service/NearbyScanService.java | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java b/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java index aaa81e51..38e47df4 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java @@ -155,13 +155,19 @@ private Presence(final long lastHeardMs, final Double latitude, final Double lon private Map namesByBeaconId = Map.of(); /** - * The tags their owner has asked to be warned about, read when the watch starts. + * The tags their owner has asked to be warned about, re-read on every check. * *

Held as the permissions because undecided means no - see * {@code UserBeaconOptions.alertOnSeparation}. Every other tag is still scanned for and still * recorded; only the noise is off, and it stays off until somebody asks for it by name. + * + *

Re-read rather than read once at startup. The switch lives in the app and this + * runs in a service that outlives it, so a set read when the watch started is a snapshot of + * what the user wanted before they went to change it. Reading it once meant turning the + * switch on did nothing at all until the service happened to restart, which from the outside + * is indistinguishable from the feature being broken. */ - private Set alertsOn = Set.of(); + private volatile Set alertsOn = Set.of(); private AccessorySightingPersister sightingPersister; private PhoneLocation phoneLocation; @@ -255,7 +261,6 @@ private void startWatching() { .subscribeOn(Schedulers.io()) .subscribe(beacons -> { this.namesByBeaconId = readNames(beacons); - this.alertsOn = this.beaconRepo.getBeaconsWithAlertsOn().blockingFirst(); this.watchThese(keyMaterialOf(beacons)); }, error -> Log.w(TAG, "Could not read the tags to watch for", error)); } @@ -384,6 +389,22 @@ private void noteHeard(final String beaconId) { private void checkForLeftBehind() { final long now = System.currentTimeMillis(); + // One small query against UserBeaconOptions per tick, on the IO scheduler this runs on. + // Kept cheap enough to do unconditionally rather than guessing when it might have moved. + // A failure keeps the previous answer: stale permissions beat none at all. + try { + final Set wanted = this.beaconRepo.getBeaconsWithAlertsOn().blockingFirst(); + if (!wanted.equals(this.alertsOn)) { + // Logged on change rather than every tick: this is the one place that says the + // switch in the app actually reached the service, which is exactly what is + // invisible when it does not. + Log.i(TAG, "Left-behind alerts are now wanted for " + wanted.size() + " tag(s)"); + this.alertsOn = wanted; + } + } catch (final Exception couldNotRead) { + Log.w(TAG, "Could not re-read which tags want an alert", couldNotRead); + } + for (final Map.Entry entry : this.presence.entrySet()) { final Presence known = entry.getValue(); From 74e3a1c41464ef86b0b9f7af54f945f7e9682b6d Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:15:36 +0200 Subject: [PATCH 42/61] Let the user choose the wait and the alarm sound Two settings under the background scan switch, because neither does anything without it. The wait is a slider from 10 to 300 seconds. The right answer here is about the person, not the tag: somebody who wants to be caught before the end of the street wants a few seconds and will accept the occasional check that finds the tag still there, and somebody who puts a bag down all day wants a minute and no interruptions. A slider rather than a number field because both ends of the range are bounded by what the check can honour, and a field invites typing a 5 that silently becomes a 10. That lower bound is why the check now runs every five seconds instead of fifteen. A tick coarser than the setting makes the setting a lie: at fifteen, asking for ten and asking for fifteen produced the same alert at the same moment. It costs one query against a tiny table and an in-memory preferences read per tick, next to a radio that is scanning continuously either way. The sound had to move off the notification channel entirely. A channel's sound is fixed when the channel is created and cannot be changed afterwards, so a sound the user picks cannot live there - that is the same immutability that already forced one channel rename today. The channel is silent now and LeftBehindAlarm plays the audio instead, on the alarm stream, on repeat until the notification is swiped, the tag turns up again, or sixty seconds pass. A loop with only one way out eventually runs in somebody's pocket for an hour. Repeating rather than chiming is the point of doing it this way at all: a channel plays its sound once, and once from a pocket during a walk is exactly what gets missed. Swiping the alert silences it and nothing else. That is deliberately not the gesture on the permanent notification, which means "stop listening" - reading "I have read this" as "turn the feature off" would be the worst possible interpretation of answering an alarm. Retired channel ids are now deleted on startup, so each version of the alert sound stops leaving a dead entry behind in the user's notification settings. Not yet verified on a device: the phone disconnected partway through. The alert path itself still needs a tag to actually go quiet. --- .../opentagviewer/SettingsActivity.java | 107 +++++++++++++ .../db/datastore/UserSettingsDataStore.java | 11 ++ .../db/repo/UserSettingsRepository.java | 17 ++ .../db/repo/model/UserSettings.java | 50 ++++++ .../service/LeftBehindAlarm.java | 145 ++++++++++++++++++ .../service/NearbyScanService.java | 129 ++++++++++++++-- app/src/main/res/layout/activity_settings.xml | 75 +++++++++ app/src/main/res/values-de/strings.xml | 4 + app/src/main/res/values-en/strings.xml | 4 + app/src/main/res/values-fr/strings.xml | 4 + app/src/main/res/values-ja/strings.xml | 4 + app/src/main/res/values-ko/strings.xml | 4 + app/src/main/res/values-nl/strings.xml | 4 + app/src/main/res/values-ru/strings.xml | 4 + app/src/main/res/values-zh-rCN/strings.xml | 4 + app/src/main/res/values-zh-rTW/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + 17 files changed, 557 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/service/LeftBehindAlarm.java diff --git a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java index 5f221a5e..76840a67 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java @@ -10,6 +10,8 @@ import static dev.wander.android.opentagviewer.util.android.TextChangedWatcherFactory.justWatchOnChanged; import android.content.Intent; +import android.media.Ringtone; +import android.media.RingtoneManager; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -24,6 +26,7 @@ import android.widget.CompoundButton; import android.widget.LinearLayout; import android.widget.TextView; +import com.google.android.material.slider.Slider; import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; @@ -219,6 +222,8 @@ protected void onCreate(Bundle savedInstanceState) { MaterialSwitch systemColors = this.findViewById(R.id.settings_app_use_system_colors); systemColors.setOnCheckedChangeListener(this::onUseSystemColorsChange); + this.setupLeftBehindSettings(); + this.setupUserInfo(); var async = this.github.getSuggestedServers().subscribe(suggestedServers -> { @@ -1276,4 +1281,106 @@ public void setButtonStage(boolean successStage) { } } } + + /** + * Wires the two left-behind settings: how long to wait, and what it sounds like. + * + *

Both are written straight through on change rather than on leaving the screen. The + * service re-reads them on its own schedule, so a value that is only in memory here is one + * the thing that uses it never sees. + */ + private void setupLeftBehindSettings() { + final Slider seconds = this.findViewById(R.id.settings_left_behind_seconds); + final TextView label = this.findViewById(R.id.settings_left_behind_seconds_label); + + final int configured = this.currentSettings.resolveLeftBehindAfterSeconds(); + seconds.setValue(configured); + label.setText(this.getString(R.string.left_behind_seconds_label, configured)); + + seconds.addOnChangeListener((slider, value, fromUser) -> { + final int chosen = Math.round(value); + label.setText(this.getString(R.string.left_behind_seconds_label, chosen)); + + if (!fromUser) { + return; + } + + this.currentSettings.setLeftBehindAfterSeconds(chosen); + this.persistCurrentSettings("left-behind delay"); + }); + + this.showChosenAlarmSound(); + this.findViewById(R.id.settings_left_behind_sound_row) + .setOnClickListener(v -> this.pickAlarmSound()); + } + + /** Writes the current sound's own name under the row, so the setting says what it does. */ + private void showChosenAlarmSound() { + final TextView value = this.findViewById(R.id.settings_left_behind_sound_value); + final String stored = this.currentSettings.getLeftBehindSoundUri(); + + if (stored == null || stored.isEmpty()) { + value.setText(R.string.left_behind_sound_default); + return; + } + + // A sound can be deleted, or live on a volume that is not mounted, long after it was + // chosen. Naming it "default" then is honest: that is what will actually play. + final Ringtone ringtone = RingtoneManager.getRingtone(this, Uri.parse(stored)); + final String title = ringtone == null ? null : ringtone.getTitle(this); + + value.setText(title == null || title.isEmpty() + ? this.getString(R.string.left_behind_sound_default) : title); + } + + /** Opens the system ringtone picker, starting from whatever is set now. */ + private void pickAlarmSound() { + final String stored = this.currentSettings.getLeftBehindSoundUri(); + + final Intent picker = new Intent(RingtoneManager.ACTION_RINGTONE_PICKER) + .putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_ALARM) + .putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, + this.getString(R.string.left_behind_sound)) + // Offering silence here would be a way to turn the alert off that leaves the + // switch reading as on, so the picker does not show it. + .putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, false) + .putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true) + .putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)) + .putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, + stored == null || stored.isEmpty() ? null : Uri.parse(stored)); + + this.alarmSoundPicker.launch(picker); + } + + /** + * The chosen alarm sound coming back from the system picker. + * + *

A null URI is the "Default" entry rather than a cancelled pick - the picker is launched + * without a silent option - and is stored as empty, which is what the service reads as "the + * system default alarm". + */ + private final ActivityResultLauncher alarmSoundPicker = + registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() != RESULT_OK || result.getData() == null) { + return; + } + + final Uri picked = result.getData().getParcelableExtra( + RingtoneManager.EXTRA_RINGTONE_PICKED_URI); + + this.currentSettings.setLeftBehindSoundUri( + picked == null ? "" : picked.toString()); + this.persistCurrentSettings("alarm sound"); + this.showChosenAlarmSound(); + }); + + /** Stores the settings object as it stands, logging rather than interrupting on failure. */ + private void persistCurrentSettings(final String what) { + this.settingsRepository.storeUserSettings(this.currentSettings) + .subscribeOn(Schedulers.io()) + .subscribe(() -> Log.i(TAG, "Stored the " + what), + error -> Log.w(TAG, "Could not store the " + what, error)); + } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java b/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java index e50e56fd..9e59de1f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java @@ -31,6 +31,17 @@ public final class UserSettingsDataStore { public static final Preferences.Key ICLOUD_OFFER_MADE = PreferencesKeys.booleanKey("icloud_offer_made"); public static final Preferences.Key SCAN_IN_BACKGROUND = PreferencesKeys.booleanKey("scan_in_background"); + /** Seconds of silence before a tag counts as left behind. Absent means the default. */ + public static final Preferences.Key LEFT_BEHIND_AFTER_SECONDS = + PreferencesKeys.intKey("left_behind_after_seconds"); + + /** + * The sound the left-behind alarm plays, as a content URI string. Empty means the system's + * default alarm sound, which is also what an unreadable or since-deleted one falls back to. + */ + public static final Preferences.Key LEFT_BEHIND_SOUND_URI = + PreferencesKeys.stringKey("left_behind_sound_uri"); + public static RxDataStore getInstance(Context context) { if (PREFERENCES_DATA_STORE == null) { PREFERENCES_DATA_STORE = new RxPreferenceDataStoreBuilder(context, SETTINGS_FILE_NAME) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java index 6ad4124f..a8920e8f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java @@ -8,6 +8,8 @@ import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.ENABLE_DEBUG_DATA; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.ICLOUD_OFFER_MADE; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.LANGUAGE; +import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.LEFT_BEHIND_AFTER_SECONDS; +import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.LEFT_BEHIND_SOUND_URI; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.MAP_PROVIDER; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.SCAN_IN_BACKGROUND; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.SHOW_APPLE_DEVICES; @@ -46,6 +48,8 @@ public UserSettings getUserSettings() { Boolean showAppleDevices = settings.get(SHOW_APPLE_DEVICES); Boolean scanInBackground = settings.get(SCAN_IN_BACKGROUND); Boolean icloudOfferMade = settings.get(ICLOUD_OFFER_MADE); + Integer leftBehindAfterSeconds = settings.get(LEFT_BEHIND_AFTER_SECONDS); + String leftBehindSoundUri = settings.get(LEFT_BEHIND_SOUND_URI); return UserSettings.builder() .anisetteServerUrl(anisetteServerUrl) @@ -61,6 +65,8 @@ public UserSettings getUserSettings() { .showAppleDevices(showAppleDevices) .scanInBackground(scanInBackground) .icloudOfferMade(icloudOfferMade) + .leftBehindAfterSeconds(leftBehindAfterSeconds) + .leftBehindSoundUri(leftBehindSoundUri) .build(); }).subscribeOn(Schedulers.io()) @@ -98,6 +104,17 @@ public Completable storeUserSettings(UserSettings userSettings) { // Null would throw; an empty string reads back as "no key supplied". mutablePreferences.set(AMAP_API_KEY, userSettings.getAmapApiKey() == null ? "" : userSettings.getAmapApiKey()); + + // Zero rather than absent for "never chosen": the key is an int key and cannot + // hold null, and resolveLeftBehindAfterSeconds already reads a non-positive value + // as the default. + mutablePreferences.set(LEFT_BEHIND_AFTER_SECONDS, + userSettings.getLeftBehindAfterSeconds() == null + ? 0 : userSettings.getLeftBehindAfterSeconds()); + // Null would throw; an empty string reads back as "use the default alarm sound". + mutablePreferences.set(LEFT_BEHIND_SOUND_URI, + userSettings.getLeftBehindSoundUri() == null + ? "" : userSettings.getLeftBehindSoundUri()); // An empty string means "not chosen", which is not the same as either mode - see // UserSettings.anisetteMode. Writing "local" here for somebody who never chose // would move an existing session onto a different machine identity. diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java index ea0bef48..9db79b66 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java @@ -135,9 +135,59 @@ public class UserSettings { */ private Boolean scanInBackground; + /** + * How many seconds of silence make a tag count as left behind. + * + *

Adjustable because the right answer is about the person, not the tag. Somebody who + * wants to be caught before the end of the street wants a few seconds and will accept the + * occasional check that finds the tag still there; somebody who puts their bag down a lot + * wants a minute and no interruptions. Neither is wrong, and no single number is right for + * both. + * + *

Null means {@link #LEFT_BEHIND_AFTER_SECONDS_DEFAULT}. See + * {@link #resolveLeftBehindAfterSeconds()}, which also enforces the floor - below it the + * check cadence, not this number, decides when the alert arrives, and a setting that + * silently does nothing is worse than one that will not go that low. + */ + private Integer leftBehindAfterSeconds; + + /** + * The alarm sound, as a content URI string, or null/empty for the system default alarm. + * + *

Held as the URI the ringtone picker handed back rather than anything resolved: the + * sound behind it can be deleted or live on a volume that is not mounted, so it is read + * defensively at the moment it is played and falls back to the default there. + */ + private String leftBehindSoundUri; + + /** What a tag's silence has to outlast before it is worth a targeted check. */ + public static final int LEFT_BEHIND_AFTER_SECONDS_DEFAULT = 30; + + /** + * The shortest silence worth offering. + * + *

A tag advertises every one to three seconds, but a scan at a duty cycle below full + * leaves gaps of its own, and the verification scan that follows takes six seconds on its + * own. Under ten there is nothing left for the number to control. + */ + public static final int LEFT_BEHIND_AFTER_SECONDS_MIN = 10; + + /** Beyond this the tag is somewhere else entirely and the alert has missed its moment. */ + public static final int LEFT_BEHIND_AFTER_SECONDS_MAX = 300; + public static final String ANISETTE_LOCAL = "local"; public static final String ANISETTE_REMOTE = "remote"; + /** The configured silence in seconds, defaulted and clamped to what the check can honour. */ + public int resolveLeftBehindAfterSeconds() { + if (this.leftBehindAfterSeconds == null || this.leftBehindAfterSeconds <= 0) { + return LEFT_BEHIND_AFTER_SECONDS_DEFAULT; + } + + return Math.max(LEFT_BEHIND_AFTER_SECONDS_MIN, + Math.min(LEFT_BEHIND_AFTER_SECONDS_MAX, this.leftBehindAfterSeconds)); + } + public boolean hasDarkThemeEnabled() { return this.useDarkTheme == Boolean.TRUE; } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/service/LeftBehindAlarm.java b/app/src/main/java/dev/wander/android/opentagviewer/service/LeftBehindAlarm.java new file mode 100644 index 00000000..ec7e12c5 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/service/LeftBehindAlarm.java @@ -0,0 +1,145 @@ +package dev.wander.android.opentagviewer.service; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.Nullable; + +/** + * Plays the left-behind alarm, on repeat, until somebody deals with it. + * + *

Why the sound is not on the notification channel. A channel's sound is fixed at the + * moment it is created and cannot be changed afterwards - setting it again in code does nothing + * for anyone who already has the channel. A sound the user picks has to be changeable, so the + * channel is left silent and the audio is played here instead. That buys the repeat as well: a + * channel plays its sound once, which is a chime, and a chime from a pocket during a walk is + * exactly the thing that gets missed. + * + *

Alarm usage, deliberately. Notification audio plays at notification volume, which on + * a phone that has been quietened is nothing at all. This is the one notification in the app + * allowed to interrupt, so it goes out the way an alarm clock does and stays audible with the + * ringer down. + * + *

It stops itself. A loop with only one way out is a loop that eventually runs in + * somebody's pocket for an hour, so {@link #MAX_DURATION_MS} ends it regardless of whether the + * notification was ever touched. Being told twice is a nuisance; a siren nobody can find is a + * reason to uninstall. + */ +public final class LeftBehindAlarm { + private static final String TAG = LeftBehindAlarm.class.getSimpleName(); + + /** + * How long the alarm repeats before giving up on being answered. + * + *

Long enough to be heard through a coat and walked back for, short enough that a phone + * left on a table does not make a scene. The notification stays either way - the sound is + * what is time-limited, not the message. + */ + static final long MAX_DURATION_MS = 60_000L; + + private final Context context; + private final Handler handler = new Handler(Looper.getMainLooper()); + + @Nullable + private MediaPlayer player; + + public LeftBehindAlarm(final Context context) { + this.context = context.getApplicationContext(); + } + + /** + * Starts the alarm, replacing one already sounding. + * + *

Replacing rather than layering: two tags left behind at once is one situation, and two + * alarm sounds over each other is just noise. Each still gets its own notification. + * + * @param soundUri what the user picked, or null/empty for the system's default alarm. + */ + public void start(@Nullable final String soundUri) { + this.stop(); + + final Uri sound = resolve(soundUri); + if (sound == null) { + Log.w(TAG, "No alarm sound available; the notification will be silent"); + return; + } + + try { + final MediaPlayer started = new MediaPlayer(); + started.setAudioAttributes(new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build()); + started.setDataSource(this.context, sound); + started.setLooping(true); + started.prepare(); + started.start(); + + this.player = started; + this.handler.postDelayed(this::stop, MAX_DURATION_MS); + + Log.i(TAG, "Left-behind alarm sounding"); + } catch (final Exception couldNotPlay) { + // A sound that has been deleted, a volume that is not mounted, an audio focus the + // system refused. None of it is worth failing the alert over: the notification is + // already posted and is the part that carries the information. + Log.w(TAG, "Could not play the left-behind alarm", couldNotPlay); + this.stop(); + } + } + + /** Silences the alarm. Safe to call when nothing is playing. */ + public void stop() { + this.handler.removeCallbacksAndMessages(null); + + final MediaPlayer sounding = this.player; + this.player = null; + + if (sounding == null) { + return; + } + + try { + if (sounding.isPlaying()) { + sounding.stop(); + } + } catch (final IllegalStateException alreadyGone) { + Log.d(TAG, "Alarm player was already finished", alreadyGone); + } finally { + sounding.release(); + } + } + + /** + * The user's choice, or the system default alarm when there is none or it cannot be read. + * + *

Falls back twice: an alarm sound the device does not have is answered with the + * notification sound rather than with silence, because this is the one alert where being + * quiet is the failure. + */ + @Nullable + private static Uri resolve(@Nullable final String soundUri) { + if (!TextUtils.isEmpty(soundUri)) { + return Uri.parse(soundUri); + } + + final Uri alarm = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM); + return alarm != null + ? alarm + : RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); + } + + /** Whether the phone's alarm stream is turned all the way down. */ + public boolean isAlarmStreamSilent() { + final AudioManager audio = this.context.getSystemService(AudioManager.class); + return audio != null && audio.getStreamVolume(AudioManager.STREAM_ALARM) == 0; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java b/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java index 38e47df4..c156cd38 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/service/NearbyScanService.java @@ -96,6 +96,17 @@ public class NearbyScanService extends Service { */ private static final String ACTION_DISMISSED = "dev.wander.opentagviewer.SCAN_DISMISSED"; + /** + * Swiping the left-behind alert away, which silences the sound and nothing else. + * + *

Deliberately not {@link #ACTION_DISMISSED}: that one is the permanent notification being + * swiped, and means "stop listening". Dismissing an alarm means "I have read it", and + * turning the whole feature off because somebody answered it would be the worst possible + * reading of that gesture. + */ + private static final String ACTION_SILENCE_ALARM = + "dev.wander.opentagviewer.SILENCE_LEFT_BEHIND"; + /** * Channel for the left-behind alert, which is loud on purpose - see {@link #alertLeftBehind}. * @@ -104,7 +115,11 @@ public class NearbyScanService extends Service { * old one, and there is no way to update it. The only way to change how an alert sounds is * to publish a new channel, so the id carries a version. */ - private static final String ALERT_CHANNEL_ID = "tag_left_behind_alarm"; + private static final String ALERT_CHANNEL_ID = "tag_left_behind_chosen_sound"; + + /** Earlier {@link #ALERT_CHANNEL_ID} values, deleted so they stop appearing in settings. */ + private static final List RETIRED_ALERT_CHANNEL_IDS = + List.of("tag_left_behind", "tag_left_behind_alarm"); /** * How often the left-behind rule is evaluated. @@ -113,8 +128,15 @@ public class NearbyScanService extends Service { * touches the radio only for one that has gone quiet - but whatever it is, it is added to * every alert. At a minute it was the largest single delay in the chain, longer than the * silence it was watching for. + * + *

Five seconds because the silence to wait for is now the user's to choose and goes as + * low as ten - see {@code UserSettings.LEFT_BEHIND_AFTER_SECONDS_MIN}. A tick coarser than + * the setting makes the setting a lie: at fifteen, asking for ten and asking for fifteen + * produced the same alert at the same moment. The two reads it costs are a query against a + * tiny table and an in-memory preferences lookup, next to a radio that is scanning + * continuously the whole time either way. */ - private static final long CHECK_INTERVAL_MS = 15_000L; + private static final long CHECK_INTERVAL_MS = 5_000L; /** What is known about a tag right now: heard since when, and where it turned up. */ private static final class Presence { @@ -168,6 +190,29 @@ private Presence(final long lastHeardMs, final Double latitude, final Double lon * is indistinguishable from the feature being broken. */ private volatile Set alertsOn = Set.of(); + + /** + * The silence a tag has to keep before it is worth checking, in milliseconds. + * + *

Re-read alongside {@link #alertsOn} and for the same reason: it is the user's to change + * from a screen that this service outlives. + */ + private volatile long quietForMs = LeftBehind.QUIET_FOR_MS; + + /** The user's chosen alarm sound, re-read with the rest. Empty means the system default. */ + private volatile String alarmSoundUri = ""; + + /** Plays that sound, on repeat, until the alert is answered. */ + private LeftBehindAlarm alarm; + + /** + * Whether the settings have been read yet in this service's life. + * + *

Only so the first read is logged even when it agrees with the defaults. "Nothing + * changed" and "the check never ran" produce the same silence in a log otherwise, and + * telling those two apart was the whole difficulty the last time this was wrong. + */ + private boolean haveReadSettings = false; private AccessorySightingPersister sightingPersister; private PhoneLocation phoneLocation; @@ -205,6 +250,7 @@ public void onCreate() { this.phoneLocation = new CachedPhoneLocation( new FusedPhoneLocation(this.getApplicationContext())); this.sightingPersister = new AccessorySightingPersister(this.beaconRepo); + this.alarm = new LeftBehindAlarm(this.getApplicationContext()); } @Override @@ -219,6 +265,13 @@ public int onStartCommand(final Intent intent, final int flags, final int startI return START_NOT_STICKY; } + if (intent != null && ACTION_SILENCE_ALARM.equals(intent.getAction())) { + this.alarm.stop(); + // Falls through to goToForeground below rather than returning: the service is still + // meant to be listening, and returning here would leave it started without the + // notification the platform requires it to have. + } + this.goToForeground(); if (this.watch == null || this.watch.isDisposed()) { @@ -241,6 +294,11 @@ public void onDestroy() { this.leftBehindCheck.dispose(); } this.leftBehindCheck = null; + if (this.alarm != null) { + // Nothing else would: the player holds no reference to the service, so a sounding + // alarm would outlive the thing that started it. + this.alarm.stop(); + } super.onDestroy(); } @@ -334,6 +392,8 @@ private void watchThese(final Map accessoryJsonByBeaconId) { error -> Log.w(TAG, "Background watch ended with an error", error), () -> Log.i(TAG, "Background watch ended")); + Log.i(TAG, "Left-behind check starting, every " + (CHECK_INTERVAL_MS / 1000) + "s"); + this.leftBehindCheck = Observable .interval(CHECK_INTERVAL_MS, CHECK_INTERVAL_MS, TimeUnit.MILLISECONDS, Schedulers.io()) @@ -376,6 +436,10 @@ private void noteHeard(final String beaconId) { + " turned up", error)); } + // Answered by the tag itself: whatever the alert was about has resolved, and a siren + // going while the thing it is about is back in earshot is just wrong. + this.alarm.stop(); + Log.d(TAG, "beaconId=" + beaconId + " is in range again"); } @@ -394,21 +458,34 @@ private void checkForLeftBehind() { // A failure keeps the previous answer: stale permissions beat none at all. try { final Set wanted = this.beaconRepo.getBeaconsWithAlertsOn().blockingFirst(); - if (!wanted.equals(this.alertsOn)) { + if (!wanted.equals(this.alertsOn) || !this.haveReadSettings) { // Logged on change rather than every tick: this is the one place that says the // switch in the app actually reached the service, which is exactly what is // invisible when it does not. Log.i(TAG, "Left-behind alerts are now wanted for " + wanted.size() + " tag(s)"); this.alertsOn = wanted; } + + final UserSettings settings = new UserSettingsRepository( + UserSettingsDataStore.getInstance(this)).getUserSettings(); + + final long configured = settings.resolveLeftBehindAfterSeconds() * 1000L; + if (configured != this.quietForMs || !this.haveReadSettings) { + Log.i(TAG, "Left-behind silence is now " + (configured / 1000) + "s"); + this.quietForMs = configured; + } + + this.alarmSoundUri = settings.getLeftBehindSoundUri() == null + ? "" : settings.getLeftBehindSoundUri(); + this.haveReadSettings = true; } catch (final Exception couldNotRead) { - Log.w(TAG, "Could not re-read which tags want an alert", couldNotRead); + Log.w(TAG, "Could not re-read the left-behind settings", couldNotRead); } for (final Map.Entry entry : this.presence.entrySet()) { final Presence known = entry.getValue(); - if (known.gone || now - known.lastHeardMs < LeftBehind.QUIET_FOR_MS) { + if (known.gone || now - known.lastHeardMs < this.quietForMs) { continue; } @@ -515,7 +592,7 @@ private void recordContactLost(final String beaconId, final Presence known, } final long couldBeAnywhereWithin = here.getAccuracyMetres() - + Math.round((LeftBehind.QUIET_FOR_MS / 1000.0) * WALKING_METRES_PER_SECOND); + + Math.round((this.quietForMs / 1000.0) * WALKING_METRES_PER_SECOND); this.beaconRepo.recordLocalSighting(beaconId, here.getLatitude(), here.getLongitude(), couldBeAnywhereWithin, 0, nowMs) @@ -546,17 +623,12 @@ private void alertLeftBehind(final String beaconId, final long lastHeardMs) { NotificationManager.IMPORTANCE_HIGH); channel.setDescription(this.getString(R.string.left_behind_channel_description)); - // **The alarm stream, not the notification one.** A notification chime is meant to - // be ignorable, and it plays at whatever the notification volume happens to be - - // which for a phone in a pocket is often nothing. This alert is only useful in the - // half minute while walking back is still easy, so it goes out the way an alarm - // clock does: alarm usage, alarm volume, and audible with the ringer down. - channel.setSound( - RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM), - new AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_ALARM) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .build()); + // **Silent on purpose, and this is not the alert going quiet.** The sound is played + // by LeftBehindAlarm instead, on the alarm stream and on repeat. A channel's sound + // is fixed when the channel is created and cannot be changed afterwards, so a sound + // the user picks cannot live here; and a channel plays it once, which is a chime, + // and a chime from a pocket during a walk is the thing that gets missed. + channel.setSound(null, null); // Long enough to be felt through a coat, and unlike a message buzz. channel.enableVibration(true); @@ -572,6 +644,11 @@ private void alertLeftBehind(final String beaconId, final long lastHeardMs) { this, beaconId.hashCode(), open, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + final Intent quiet = new Intent(this, NearbyScanService.class) + .setAction(ACTION_SILENCE_ALARM); + final PendingIntent silence = PendingIntent.getService( + this, 2, quiet, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + final CharSequence howLongAgo = DateUtils.getRelativeTimeSpanString( lastHeardMs, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS); @@ -586,6 +663,8 @@ private void alertLeftBehind(final String beaconId, final long lastHeardMs) { // to read later. .setCategory(NotificationCompat.CATEGORY_ALARM) .setContentIntent(show) + // Swiping it away is the answer to it, so that is where the sound stops. + .setDeleteIntent(silence) .setAutoCancel(true) .build(); @@ -593,6 +672,14 @@ private void alertLeftBehind(final String beaconId, final long lastHeardMs) { // behind is two things to go back for. manager.notify(beaconId.hashCode(), alert); + this.alarm.start(this.alarmSoundUri); + + if (this.alarm.isAlarmStreamSilent()) { + // Worth saying out loud rather than leaving as a mystery: the alert did everything + // it was asked to and still made no noise, and the reason is not in this app. + Log.w(TAG, "Alarm volume is at zero; the left-behind alert will be silent"); + } + Log.i(TAG, "Alerted that beaconId=" + beaconId + " looks left behind"); } @@ -638,6 +725,14 @@ private void turnBackgroundScanningOff() { private void goToForeground() { final NotificationManager manager = this.getSystemService(NotificationManager.class); + // **Tidying up after our own versioning.** Each change to how the alert sounds had to + // publish a new channel, because a channel's settings are fixed once created. The old + // ones are unused but stay in the user's notification settings forever, so each retired + // id would leave another dead entry there under a name that still looks live. + for (final String retired : RETIRED_ALERT_CHANNEL_IDS) { + manager.deleteNotificationChannel(retired); + } + if (manager.getNotificationChannel(CHANNEL_ID) == null) { final NotificationChannel channel = new NotificationChannel( CHANNEL_ID, diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index bc03ae94..c9eadbaf 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -344,6 +344,81 @@ android:textColor="?attr/colorOnSurfaceVariant" android:textSize="13sp" /> + + + + + + + + + + + + + + + + + Zuletzt dort gehört, wo du %1$s warst. Zum Anzeigen tippen. Warnen, wenn zurückgelassen Schlägt Alarm, wenn dieses Tag nicht mehr zu hören ist und du weitergegangen bist. Setzt den Hintergrund-Empfang voraus. + Nach %1$d Sekunden warnen + Wie lange ein Tag nicht zu hören sein muss, bevor die App prüft, ob du es zurückgelassen hast. Kürzer warnt früher und prüft öfter. + Alarmton + Standard-Alarmton \ 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 4a2b97b1..a7709fbb 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -350,4 +350,8 @@ You can set this up now, or any time later from Settings. Last heard where you were %1$s. Tap to see the place. Warn if left behind Sounds an alarm when this tag stops being heard and you have moved on. Needs background listening to be on. + Warn after %1$d seconds + How long a tag has to go unheard before the app checks whether you have left it behind. Shorter catches you sooner and checks more often. + Alarm sound + Default alarm sound \ 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 c4b3b64d..bf8960e2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -350,4 +350,8 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.< Entendue pour la dernière fois là où vous étiez %1$s. Touchez pour voir l\'endroit. Avertir si oublié Déclenche une alarme quand cette balise n\'est plus entendue et que vous êtes parti. Nécessite l\'écoute en arrière-plan. + Avertir après %1$d secondes + Durée pendant laquelle un tag doit rester inaudible avant que l\'application vérifie si vous l\'avez oublié. Plus court avertit plus tôt et vérifie plus souvent. + Son de l\'alarme + Son d\'alarme par défaut \ 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 5ba7bdd1..3d75f30d 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -350,4 +350,8 @@ %1$sにいた場所で最後に受信しました。タップして場所を表示します。 置き忘れたら警告 このタグが受信できなくなり、その場を離れたときにアラームを鳴らします。バックグラウンド受信が必要です。 + %1$d 秒後に通知 + タグの信号が途絶えてから、置き忘れを確認するまでの時間です。短いほど早く気づき、確認回数も増えます。 + アラーム音 + 既定のアラーム音 \ 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 8a542e21..cd020eb4 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -350,4 +350,8 @@ %1$s에 있던 곳에서 마지막으로 수신했습니다. 탭하여 위치를 확인하세요. 두고 오면 경고 이 태그가 더 이상 수신되지 않고 자리를 떠났을 때 알람을 울립니다. 백그라운드 수신이 필요합니다. + %1$d초 후 경고 + 태그 신호가 끊긴 뒤 물건을 두고 왔는지 확인하기까지의 시간입니다. 짧을수록 빨리 알아차리고 더 자주 확인합니다. + 알람음 + 기본 알람음 \ 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 34922aa3..e76dab4a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -350,4 +350,8 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen. Laatst gehoord waar je %1$s was. Tik om de plek te zien. Waarschuwen bij achterlaten Slaat alarm als deze tag niet meer te horen is en je verder bent gelopen. Vereist luisteren op de achtergrond. + Waarschuwen na %1$d seconden + Hoe lang een tag onhoorbaar moet blijven voordat de app controleert of je hem hebt laten liggen. Korter waarschuwt eerder en controleert vaker. + Alarmgeluid + Standaard alarmgeluid \ 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 cbe787b9..291f186c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -350,4 +350,8 @@ Последний сигнал там, где вы были %1$s. Нажмите, чтобы увидеть место. Предупреждать, если забыта Подаёт сигнал, когда метка перестала быть слышна, а вы ушли. Требует фонового приёма. + Предупредить через %1$d сек. + Сколько метка должна молчать, прежде чем приложение проверит, не забыли ли вы её. Меньше — раньше предупреждение и чаще проверки. + Звук будильника + Стандартный звук будильника \ 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 3d1cf4ba..0f987eb2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -350,4 +350,8 @@ 在你%1$s所在的位置最后一次接收到。点按查看地点。 遗落时提醒 当此标签不再被接收且你已离开时发出提示音。需要开启后台接收。 + %1$d 秒后提醒 + 标签失去信号多久后,应用才检查你是否把它落下了。时间越短提醒越早,检查也越频繁。 + 报警声 + 默认报警声 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4408bd95..bf4fa629 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -350,4 +350,8 @@ 在你%1$s所在的位置最後一次接收到。輕觸查看地點。 遺留時提醒 當此標籤不再被接收且你已離開時發出提示音。需要開啟背景接收。 + %1$d 秒後提醒 + 標籤失去訊號多久後,應用程式才檢查你是否把它落下了。時間越短提醒越早,檢查也越頻繁。 + 警報聲 + 預設警報聲 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0a5096bb..a89c36db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -383,4 +383,8 @@ You can set this up now, or any time later from Settings. Last heard where you were %1$s. Tap to see the place. Warn if left behind Sounds an alarm when this tag stops being heard and you have moved on. Needs background listening to be on. + Warn after %1$d seconds + How long a tag has to go unheard before the app checks whether you have left it behind. Shorter catches you sooner and checks more often. + Alarm sound + Default alarm sound From 205cf4709ceab4ffdeaa29a5ad7507c20587c549 Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:19:58 +0200 Subject: [PATCH 43/61] Put a tag on the map where this phone heard it, not where iCloud last saw it A tag could be audible over Bluetooth in the same room while the map went on drawing the last position Apple's network reported, sometimes hours old. Two separate reasons, both fixed here. The screens never wrote a position at all. AccessorySightingPersister kept the alignment and the battery reading from a sighting and stopped there; the only code that ever recorded where a tag was heard was NearbyScanService, on the two edges it watches. So with background scanning off - which is every fresh install - nothing wrote a local position ever, no matter how long the app sat open with the tag in range. It now takes an optional PhoneLocation and records the position on a sighting. The screens pass one and the service passes null, deliberately: the service already covers its own edges precisely so it is not reading a location on every advertisement while nobody is looking, and a screen is the opposite situation - the app is open, somebody is watching, and the fix is cheap because the foreground is the only state its location permission covers. No throttle is added here because two already apply: the fix comes from a cache that asks the platform once a minute, and recordLocalSighting drops anything that has not moved 25 metres or waited a quarter of an hour. The second reason is that a written position was invisible until the next fetch. The map draws from an in-memory history that only the network fetch filled, so a local row sat in the database being correct and unread while the screen showed the older answer. recordLocalSighting now hands back the report it wrote, and the map merges it through the same history object the fetch uses and redraws. Handed back rather than rebuilt by the caller so there is one definition of what a local report is. Verified on a device: a tag that had produced "No locations held" and "cannot be drawn" on every redraw now produces neither, and the log shows the write that changed it. The live redraw path itself was not caught in the act - the fifteen minute rule blocks a second write for the same tag - so that half rests on the merge being the same call the fetch already makes. --- .../AccessorySightingPersister.java | 90 +++++++++++++++++++ .../opentagviewer/DeviceInfoActivity.java | 3 +- .../android/opentagviewer/MapsActivity.java | 19 +++- .../db/repo/BeaconRepository.java | 10 ++- 4 files changed, 117 insertions(+), 5 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 502eb1ff..88bc209e 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/AccessorySightingPersister.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/AccessorySightingPersister.java @@ -2,6 +2,8 @@ import android.util.Log; +import androidx.annotation.Nullable; + import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; @@ -9,7 +11,9 @@ 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.data.model.BeaconLocationReport; 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 every caller and both kinds of @@ -45,8 +49,57 @@ public final class AccessorySightingPersister { private final BeaconRepository beaconRepo; + /** + * Where the phone is, or null for a caller that records position some other way. + * + *

The screens pass one, {@code NearbyScanService} passes null. Not an oversight: + * the service already writes a position on the two edges it cares about, arriving and going + * quiet, precisely so that it is not reading a location on every advertisement while nobody + * is looking. A screen is the opposite situation - somebody has the app open and is watching + * a tag be found - and the fix is cheap there because the app is in the foreground, which is + * the only state its location permission covers anyway. + * + *

Without this the map kept showing the last thing Apple's network said, while the same + * screen was reporting the tag as audible right now. Two answers to "where is it", and the + * worse one was the one being drawn. + */ + @Nullable + private final PhoneLocation phoneLocation; + + /** + * Told when a position was actually written, so a screen can show it without waiting. + * + *

Because a row nobody redraws is a row nobody sees. The map draws from what the + * last network fetch handed it, so a position written between fetches sat in the database + * being correct and invisible, and the screen went on showing Apple's older answer for the + * same tag. Fired only for a write that happened - a sighting dropped by the 25 metre rule + * changes nothing on screen and is not worth a redraw. + * + *

Called on the Rx io thread. A listener that touches views has to get itself onto the + * main thread. + */ + public interface LocalPositionListener { + void onWritten(String beaconId, BeaconLocationReport report); + } + + @Nullable + private final LocalPositionListener localPositionListener; + public AccessorySightingPersister(final BeaconRepository beaconRepo) { + this(beaconRepo, null, null); + } + + public AccessorySightingPersister(final BeaconRepository beaconRepo, + @Nullable final PhoneLocation phoneLocation) { + this(beaconRepo, phoneLocation, null); + } + + public AccessorySightingPersister(final BeaconRepository beaconRepo, + @Nullable final PhoneLocation phoneLocation, + @Nullable final LocalPositionListener localPositionListener) { this.beaconRepo = beaconRepo; + this.phoneLocation = phoneLocation; + this.localPositionListener = localPositionListener; } /** @@ -62,6 +115,43 @@ public AccessorySightingPersister(final BeaconRepository beaconRepo) { public void onSighting(final NearbyTagSighting sighting, final String mac) { this.maybeCorrectAlignment(sighting, mac); this.persistLastSighting(sighting); + this.maybeRecordWhereItWasHeard(sighting); + } + + /** + * Writes where this phone was when it heard the tag, if that is worth keeping. + * + *

Unthrottled here on purpose. Two things already limit it: the fix comes from a + * cache that only asks the platform once a minute, and + * {@code BeaconRepository#recordLocalSighting} drops anything that has not moved 25 metres or + * waited a quarter of an hour. Adding a third rule here would only make the real one harder + * to find. + * + *

Silent when there is no fix. A phone indoors with no recent location has nothing to say + * about where the tag is, and a report at a guessed position is worse than no report. + */ + private void maybeRecordWhereItWasHeard(final NearbyTagSighting sighting) { + if (this.phoneLocation == null) { + return; + } + + final PhoneLocation.Fix fix = this.phoneLocation.lastKnown(); + if (fix == null) { + return; + } + + this.beaconRepo.recordLocalSighting( + sighting.getBeaconId(), fix.getLatitude(), fix.getLongitude(), + Math.round(fix.getAccuracyMetres()), sighting.getStatusByte(), + sighting.getSeenAtMs()) + .subscribe(written -> { + if (written.isPresent() && this.localPositionListener != null) { + this.localPositionListener.onWritten( + sighting.getBeaconId(), written.get()); + } + }, error -> Log.w(TAG, + "Could not record where beaconId=" + sighting.getBeaconId() + + " was heard", 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 2a536e58..b955b717 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java @@ -222,7 +222,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 CachedPhoneLocation(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 69242df9..74b5b9e0 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -499,7 +499,9 @@ 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 CachedPhoneLocation(new FusedPhoneLocation(this.getApplicationContext())), + this::onHeardHere); this.fusedLocationClient = LocationServices.getFusedLocationProviderClient(this); @@ -2192,6 +2194,21 @@ private Observable> reverseGeocode(double latitude, double longitu .subscribeOn(Schedulers.io()); } + /** + * Puts a position this phone just heard onto the map, without waiting for a fetch. + * + *

The map draws from {@link #beaconLocations}, which until now only the network fetch + * filled. A tag heard over Bluetooth was therefore written to the database and left off the + * screen until the next scheduled refresh - so the map went on showing Apple's older answer + * for a tag that was audible in the same room. Merged through the same history object the + * fetch uses, so the newer of the two wins on its own and no special case is needed for + * which source a position came from. + */ + private void onHeardHere(final String beaconId, final BeaconLocationReport report) { + this.beaconLocations.merge(beaconId, List.of(report)); + this.runOnUiThread(this::showLastDeviceLocations); + } + private synchronized void showLastDeviceLocations() { for (BeaconData beaconData : this.beacons.values()) { BeaconInformation beacon = beaconData.getInfo(); 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 cdf8eaf8..b47a3a97 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 @@ -632,7 +632,7 @@ public Completable storeLastSighting( * * @return true when a row was written, so a caller can log or test the decision. */ - public Observable recordLocalSighting( + public Observable> recordLocalSighting( final String beaconId, final double latitude, final double longitude, @@ -651,7 +651,7 @@ public Observable recordLocalSighting( latitude, longitude, heardAtUnixMs); if (!keep) { - return false; + return Optional.empty(); } // Built as the shared model first so the id comes out of the same hasher the network @@ -689,7 +689,11 @@ public Observable recordLocalSighting( .build()); Log.d(TAG, "Wrote a local position for beaconId=" + beaconId); - return true; + + // Handed back rather than announced as a bare boolean so a caller that draws a map + // can put this on it without rebuilding the same report from the same inputs and + // risking a second, subtly different definition of what a local report looks like. + return Optional.of(report); }).subscribeOn(Schedulers.io()); } From a79a5189cbb95cacf24d4073e1315d504f07becb Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:48:38 +0200 Subject: [PATCH 44/61] Say what deriving a candidate window costs, in indices and seconds The candidate derivation is the one expensive call in the BLE path, and the only one whose price scales with how stale an alignment is. How far a search can be widened before it stops being affordable is therefore a question about this number, and the number was documented with an adjective: "several times slower under Chaquopy". Nobody can size a background task with that. Measured with this line, on a device, it turns out to be two numbers rather than one. The first derivation in a process costs about twelve seconds for a 383 index window; the next ones cost 0.7 to 1.1 seconds for the same window. The library caches nothing - three runs of the same window, a fresh accessory object, an adjacent window and a distant one all cost the same 3.3 seconds per thousand on a desktop - so that first call is a one-off warm-up and not a derivation cost at all. What is left is a marginal rate of roughly 2 to 3 seconds per thousand indices on an idle phone, against 3.3 on the desktop, rising to 15 or 30 when the app is starting up and competing with itself. The phone is not several times slower. It has a warm-up that was being counted as though it were. Printed per index rebuild rather than per sighting: rare enough to cost nothing, often enough to catch a device far slower than the ones this was measured on. --- app/src/main/python/main.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 1c40ecb0..254ce8ad 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -1090,6 +1090,23 @@ def accessoryFromJson(accessoryJson: str) -> StoredAccessory: _MAC_CANDIDATE_MAX_INDICES = 1000 +def _reportDerivationCost(width, derived, started): + """Says what deriving a candidate window actually cost, in indices and in seconds. + + This is the one expensive call in the BLE path and the only one whose price scales with + how stale an alignment is, so how far a search can be widened before it stops being + affordable is a question about this number. It was answered with adjectives for a long + time - "several times slower under Chaquopy" - which is not a number anybody can size a + background task with. Printed per index rebuild rather than per sighting, which is rare + enough to be free and often enough to catch a device that is far slower than the desktop. + """ + elapsed = time.perf_counter() - started + count = 0 if derived is None else len(derived) + per_thousand = (elapsed / width * 1000) if width else 0.0 + print(f"Derived {count} candidate address(es) over {width} index/indices " + f"in {elapsed:.2f}s ({per_thousand:.2f}s per 1000)") + + def currentMacAddresses(accessoryJson: str) -> dict[str, int] | None: """ The BLE MAC address(es) this accessory might currently be advertising, each with its index. @@ -1119,6 +1136,7 @@ def currentMacAddresses(accessoryJson: str) -> dict[str, int] | None: accessory = accessoryFromJson(accessoryJson) now = datetime.now(timezone.utc) + started = time.perf_counter() width = _isAlignmentWide( accessory, now - _MAC_CANDIDATE_MARGIN, now + _MAC_CANDIDATE_MARGIN) @@ -1134,12 +1152,16 @@ def currentMacAddresses(accessoryJson: str) -> dict[str, int] | None: 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 { + derived = { key.mac_address: index for index, key in accessory.keys_between(max(0, bottom), top) } + _reportDerivationCost(_MAC_CANDIDATE_MAX_INDICES, derived, started) + return derived - return accessory.current_mac_addresses(margin=_MAC_CANDIDATE_MARGIN) + derived = accessory.current_mac_addresses(margin=_MAC_CANDIDATE_MARGIN) + _reportDerivationCost(width, derived, started) + return derived except Exception: print(f"currentMacAddresses failed: {traceback.format_exc()}") return None From 86ab833c0df161e0347b8de2c2d2c8f1fa3395f5 Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:56:53 +0200 Subject: [PATCH 45/61] Let a caller ask what range to search and derive it a piece at a time Groundwork for keeping a derived index instead of rebuilding it every time the app starts. Two functions, split along the line that matters: what the answer would cost, and the answer itself. candidateWindow says which key index range is worth scanning right now, bounded exactly as currentMacAddresses bounds it, without deriving anything. Knowing the range is what lets a caller work out which part it is missing, and not paying for the part it already has is the entire point of keeping it. addressesBetween derives a range it is told rather than one it picks. A caller widening its search downward has to be able to name the piece below the one currentMacAddresses would have chosen, which a function that decided for itself could never be asked for. Splitting a range and joining the results yields exactly the same set of addresses as asking for it whole. That property is what the stored copy rests on, so it is asserted rather than assumed. It also turned up the limit of that property, which a test now pins down: the address set is pure, but the index attached to it is not. keys_between de-duplicates and a secondary key covers 96 primary indices, so it is reported at the first index the call's own range reaches - 19100 when asked for 19100..19160, and 19131 for the same address when asked for 19131..19160. Primary keys occur at one index and do not move. Nothing downstream is hurt, because a secondary match is already only a hint that recordAccessorySeen verifies and refuses to align on, but a caller that stored the pair and later read the index as exact would be reading where it started looking. --- app/src/main/python/main.py | 76 ++++++++++++++++++++++++ app/src/test/python/test_main.py | 99 ++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 254ce8ad..f91b8910 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -1090,6 +1090,82 @@ def accessoryFromJson(accessoryJson: str) -> StoredAccessory: _MAC_CANDIDATE_MAX_INDICES = 1000 +def candidateWindow(accessoryJson: str): + """The key index range worth scanning for this accessory right now, without deriving it. + + **Cheap on purpose.** `currentMacAddresses` answers the same question and pays for the + answer, which is fine when the addresses are what you want and wasteful when all you need + to know is which part of the range you are missing. Deciding that is what lets a caller + keep what it derived last time and ask only for the rest, and the whole point of keeping + it is not paying this cost again. + + Bounded exactly as `currentMacAddresses` bounds it, so the two never disagree about which + slice is the live one. + + Returns a mapping with `lo` and `hi` inclusive, or None if the accessory cannot be read. + """ + try: + accessory = accessoryFromJson(accessoryJson) + + now = datetime.now(timezone.utc) + top = accessory.get_max_index(now + _MAC_CANDIDATE_MARGIN) + width = _isAlignmentWide( + accessory, now - _MAC_CANDIDATE_MARGIN, now + _MAC_CANDIDATE_MARGIN) + + if width > _MAC_CANDIDATE_MAX_INDICES: + bottom = top - _MAC_CANDIDATE_MAX_INDICES + else: + bottom = accessory.get_min_index(now - _MAC_CANDIDATE_MARGIN) + + return {"lo": max(0, bottom), "hi": top} + except Exception: + print(f"candidateWindow failed: {traceback.format_exc()}") + return None + + +def addressesBetween(accessoryJson: str, lo: int, hi: int): + """The addresses this accessory can advertise at every index from `lo` to `hi` inclusive. + + **The set of addresses never goes out of date, and that is what a stored copy rests on.** + An address is a pure function of the accessory's keys and an index, so an address derived + once is still one this accessory can advertise; only which part of the range is worth + watching moves, and that is `candidateWindow`'s answer rather than this one's. Splitting a + range into pieces and joining the results yields exactly the same set as asking for it + whole, which is what lets a caller widen its search a piece at a time. + + **The index attached to an address is not pure, and must not be treated as though it + were.** `keys_between` de-duplicates, and a secondary key covers 96 consecutive primary + indices, so it is reported at the first index the *call's own* range happens to reach: + ask for 19100..19160 and it comes back at 19100, ask for 19131..19160 and the same address + comes back at 19131. Primary keys occur at exactly one index and do not move. This costs + nothing downstream because a secondary match is already only a hint - `recordAccessorySeen` + verifies it and refuses to align on one - but a caller that stored the pair and later + trusted the index as exact would be trusting an artefact of where it started looking. + + Deliberately takes the range rather than working it out. A caller widening its search a + piece at a time needs to say which piece, and a function that decided for itself could not + be asked for the piece below the one it would have chosen. + + Returns None on failure, which a caller must tell apart from an empty range. + """ + try: + if hi < lo: + return {} + + accessory = accessoryFromJson(accessoryJson) + + started = time.perf_counter() + derived = { + key.mac_address: index + for index, key in accessory.keys_between(max(0, lo), hi) + } + _reportDerivationCost(hi - max(0, lo) + 1, derived, started) + return derived + except Exception: + print(f"addressesBetween failed: {traceback.format_exc()}") + return None + + def _reportDerivationCost(width, derived, started): """Says what deriving a candidate window actually cost, in indices and in seconds. diff --git a/app/src/test/python/test_main.py b/app/src/test/python/test_main.py index 54df063f..532aaba3 100644 --- a/app/src/test/python/test_main.py +++ b/app/src/test/python/test_main.py @@ -1570,3 +1570,102 @@ def test_afailureToCloseIsReportedRatherThanRaised(): def test_closingNothingIsHarmless(): assert main.closeAccount(None) is False + +def test_candidate_window_agrees_with_what_current_mac_addresses_derives(): + """The cheap answer and the expensive one must describe the same slice. + + If they drift apart, a caller keeping what it derived would keep the wrong part of the + range and go on missing the tag while believing it had covered it. + """ + accessory = json.dumps(_freshly_aligned_accessory()) + + window = main.candidateWindow(accessory) + macs = main.currentMacAddresses(accessory) + + assert window is not None + assert set(macs.values()) <= set(range(window["lo"], window["hi"] + 1)) + + +def test_candidate_window_is_bounded_for_a_stale_alignment(): + """A window too wide to derive whole is reported as the bounded slice, not the true width.""" + accessory = json.dumps(_unaligned_accessory( + datetime.now(timezone.utc) - timedelta(days=400))) + + window = main.candidateWindow(accessory) + + assert window is not None + assert window["hi"] - window["lo"] <= main._MAC_CANDIDATE_MAX_INDICES + + +def test_addresses_between_covers_exactly_the_requested_range(): + accessory = json.dumps(_freshly_aligned_accessory()) + + derived = main.addressesBetween(accessory, 19100, 19150) + + assert derived is not None + assert derived + assert set(derived.values()) <= set(range(19100, 19151)) + + +def test_addresses_between_is_stable_across_calls(): + """The mapping is a pure function of the keys and the index, which is what makes it + safe to store: a pair derived today has to still be true when it is read back.""" + accessory = json.dumps(_freshly_aligned_accessory()) + + first = main.addressesBetween(accessory, 19100, 19120) + second = main.addressesBetween(accessory, 19100, 19120) + + assert first == second + + +def test_addresses_between_pieces_join_up_into_the_whole_set(): + """Widening a search a piece at a time must reach the same addresses as asking once. + + This is the property the stored copy rests on. Without it, extending the range would + leave gaps that nothing would ever go back for. + """ + accessory = json.dumps(_freshly_aligned_accessory()) + + whole = main.addressesBetween(accessory, 19100, 19160) + lower = main.addressesBetween(accessory, 19100, 19130) + upper = main.addressesBetween(accessory, 19131, 19160) + + joined = dict(lower) + joined.update(upper) + + assert set(joined) == set(whole) + + +def test_a_secondary_key_is_reported_at_wherever_the_range_started(): + """The address set is pure; the index attached to it is not, for secondary keys. + + A secondary key covers 96 primary indices and `keys_between` de-duplicates, so it is + reported at the first index the call's own range reaches. Asserted rather than merely + documented because a caller storing the pair and later trusting the index as exact would + be trusting an artefact of where it started looking - see `addressesBetween`. + """ + accessory = json.dumps(_freshly_aligned_accessory()) + + whole = main.addressesBetween(accessory, 19100, 19160) + upper = main.addressesBetween(accessory, 19131, 19160) + + moved = {mac for mac in set(whole) & set(upper) if whole[mac] != upper[mac]} + + assert moved, "expected at least one secondary key to be re-attributed" + for mac in moved: + assert whole[mac] < 19131 <= upper[mac] + + +def test_addresses_between_refuses_nothing_for_an_empty_range(): + accessory = json.dumps(_freshly_aligned_accessory()) + + assert main.addressesBetween(accessory, 500, 499) == {} + + +def test_addresses_between_returns_none_for_an_unreadable_accessory(): + assert main.addressesBetween("not json at all", 0, 10) is None + + +def test_candidate_window_returns_none_for_an_unreadable_accessory(): + assert main.candidateWindow("not json at all") is None + From 67f9db8796ebf511727ea28faff67ffc5c18e5b8 Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:10:37 +0200 Subject: [PATCH 46/61] Keep the addresses derived for a tag instead of deriving them every launch The nearby index was rebuilt from scratch on every app start, which meant paying the full derivation at the one moment it costs the most: measured at 25 to 38 seconds per tag on a device that was also busy starting up, against two to three seconds per thousand indices when idle. Three tags, every launch, for an answer that was identical to the one thrown away when the process last exited. It is safe to keep because an address is a pure function of the accessory's keys and a key index. It cannot go stale and it cannot become wrong; it can only be incomplete. The alignment moving does not invalidate it either, since alignment decides which part of the range is worth watching rather than what the range contains. So the store holds the addresses and the range they were derived over, and a rebuild asks Python only for the part it is missing. On a cold start with a populated store the log now reads "Reusing 390 stored address(es) covering 6531..6914" followed by "Derived 3 candidate address(es) over 1 index" - one index instead of 384, because that is how far the window had crept in the meantime. The index each address was derived at is deliberately not kept. It is not stable: a secondary key is reported at whatever index the deriving call's own range began at, so storing it would preserve an artefact of how the work was split. It reads back as absent, which travels on as a missing hint, which costs Python one wide check - what every check cost before hints existed. That made the hint nullable end to end, which it always should have been. Extended only while the stored range and the wanted window still touch. When they do not, the app has not run for a very long time and the honest answer is a fresh derivation: recording a range as covered when the middle of it was never derived would mean silently never looking there again. Written through a temporary file and renamed, because the failure that would not announce itself is a truncated file read back as a narrower range. Its tests are about that: a short file, a file from another format version, and a beacon id shaped like a path all have to read as nothing held rather than as something partly good. --- .../ble/DerivedAddressStore.java | 240 ++++++++++++++++++ .../opentagviewer/ble/NearbyTagIndex.java | 115 ++++++++- .../opentagviewer/ble/NearbyTagSighting.java | 5 +- .../opentagviewer/ble/NearbyTagWatcher.java | 23 +- .../python/AccessoryMacResolver.java | 63 +++++ .../python/ChaquopyAccessoryMacResolver.java | 55 ++++ .../ble/DerivedAddressStoreTest.java | 201 +++++++++++++++ .../opentagviewer/ble/NearbyTagIndexTest.java | 4 +- 8 files changed, 696 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/DerivedAddressStore.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/ble/DerivedAddressStoreTest.java diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/DerivedAddressStore.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/DerivedAddressStore.java new file mode 100644 index 00000000..dbde9f0e --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/DerivedAddressStore.java @@ -0,0 +1,240 @@ +package dev.wander.android.opentagviewer.ble; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Keeps the addresses derived for a tag, so they are derived once rather than once per app start. + * + *

Why this is safe to keep at all. An address is a pure function of the accessory's + * keys and a key index, so an address derived today is still one that accessory can advertise + * tomorrow. Nothing here can go stale or wrong; it can only be incomplete. That is what lets a + * wider range simply be written over a narrower one, with no event ever invalidating what is + * already there. In particular the alignment moving does not, because alignment decides which + * part of the range is worth watching, not what the range contains. + * + *

Why it is worth keeping. Deriving costs about two to three seconds per thousand + * indices on an idle phone, and ten times that while the app is starting up and competing with + * itself, which is exactly when the index was being rebuilt. Paying it once per tag instead of + * once per launch is what makes a range wide enough for a long-missing tag affordable at all. + * + *

What is deliberately not kept: the index each address was derived at. That number is + * not stable. A secondary key is reported at whatever index the deriving call's own range began + * at, so the same address comes back against a different index depending on how the range + * happened to be split. Storing it would preserve an artefact of how the work was divided rather + * than a fact about the tag. The index is still useful as a hint, so it stays in memory for the + * window derived this session and is simply absent for addresses recovered from the file. A + * missing hint costs one wide check inside Python, which is what every check cost before hints + * existed. + * + *

A cache in the file sense too: losing it costs time and never correctness, so it lives in + * the app's files directory rather than in the database, and may be deleted at any point. + */ +public final class DerivedAddressStore { + private static final String TAG = DerivedAddressStore.class.getSimpleName(); + + /** Bumped when the layout below changes, so an older file is discarded rather than misread. */ + private static final int FORMAT_VERSION = 1; + + private static final String DIRECTORY = "derived-addresses"; + + private static final String SUFFIX = ".bin"; + + private final File directory; + + public DerivedAddressStore(final File filesDir) { + this.directory = new File(filesDir, DIRECTORY); + } + + /** What was derived for one tag, and the index range it was derived over. */ + public static final class Derived { + private final int lo; + private final int hi; + private final Map addresses; + + public Derived(final int lo, final int hi, final Map addresses) { + this.lo = lo; + this.hi = hi; + this.addresses = addresses; + } + + public int getLo() { + return this.lo; + } + + public int getHi() { + return this.hi; + } + + /** Address to the index it was derived at, where that is known, and null where it is not. */ + public Map getAddresses() { + return this.addresses; + } + + /** Whether this already holds everything an inclusive range would produce. */ + public boolean covers(final int wantedLo, final int wantedHi) { + return this.lo <= wantedLo && wantedHi <= this.hi; + } + } + + /** + * What has been derived for this tag, or null if nothing has. + * + *

Never throws for a damaged or truncated file. A cache that cannot be read is a cache + * that has not been written yet, and the caller then derives from scratch exactly as it + * would have done anyway. + */ + @Nullable + public Derived load(final String beaconId) { + final File file = this.fileFor(beaconId); + if (!file.isFile()) { + return null; + } + + try (DataInputStream in = new DataInputStream(new FileInputStream(file))) { + if (in.readInt() != FORMAT_VERSION) { + Log.d(TAG, "Discarding a derived-address file written by another version"); + return null; + } + + final int lo = in.readInt(); + final int hi = in.readInt(); + final int count = in.readInt(); + + final Map addresses = new HashMap<>(Math.max(16, count * 2)); + final byte[] mac = new byte[6]; + for (int i = 0; i < count; i++) { + in.readFully(mac); + addresses.put(formatMac(mac), null); + } + + return new Derived(lo, hi, addresses); + } catch (final IOException | RuntimeException unreadable) { + // Truncated by a kill mid-write, or written by a build that packed it differently. + Log.d(TAG, "Could not read the derived addresses for beaconId=" + beaconId + + "; deriving them again", unreadable); + return null; + } + } + + /** + * Writes what has been derived for this tag, replacing whatever was there. + * + *

Through a temporary file and a rename, so being killed halfway leaves the previous copy + * rather than a shorter one. A truncated file would read back as a narrower covered range + * than was actually derived, and the missing part would be derived again on every launch + * with nothing ever reporting that it had been lost. + */ + public void save(final String beaconId, final int lo, final int hi, + final Map addresses) { + if (!this.directory.isDirectory() && !this.directory.mkdirs()) { + Log.w(TAG, "Could not create " + this.directory + "; not keeping derived addresses"); + return; + } + + final File target = this.fileFor(beaconId); + final File temporary = new File(target.getPath() + ".tmp"); + + try (DataOutputStream out = new DataOutputStream(new FileOutputStream(temporary))) { + out.writeInt(FORMAT_VERSION); + out.writeInt(lo); + out.writeInt(hi); + out.writeInt(addresses.size()); + + for (final String address : addresses.keySet()) { + final byte[] mac = parseMac(address); + if (mac != null) { + out.write(mac); + } + } + } catch (final IOException couldNotWrite) { + Log.w(TAG, "Could not write the derived addresses for beaconId=" + beaconId, + couldNotWrite); + temporary.delete(); + return; + } + + if (!temporary.renameTo(target) && (!target.delete() || !temporary.renameTo(target))) { + Log.w(TAG, "Could not replace the derived addresses for beaconId=" + beaconId); + temporary.delete(); + } + } + + /** Forgets what was derived for tags the user no longer has. */ + public void forgetAllExcept(final Set beaconIds) { + final File[] files = this.directory.listFiles(); + if (files == null) { + return; + } + + for (final File file : files) { + final String name = file.getName(); + if (!name.endsWith(SUFFIX)) { + continue; + } + + final String stored = name.substring(0, name.length() - SUFFIX.length()); + + boolean wanted = false; + for (final String beaconId : beaconIds) { + if (sanitise(beaconId).equals(stored)) { + wanted = true; + break; + } + } + + if (!wanted && !file.delete()) { + Log.d(TAG, "Could not delete stale derived addresses at " + file); + } + } + } + + private File fileFor(final String beaconId) { + return new File(this.directory, sanitise(beaconId) + SUFFIX); + } + + /** + * A beacon id reduced to something that can only name a file in this directory. + * + *

Beacon ids are UUIDs in practice, but this builds a path, and a value that arrived from + * an imported file has no business deciding which directory it lands in. + */ + private static String sanitise(final String beaconId) { + return beaconId.replaceAll("[^A-Za-z0-9_-]", "_"); + } + + private static String formatMac(final byte[] mac) { + return String.format(Locale.ROOT, "%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + } + + @Nullable + private static byte[] parseMac(final String address) { + final String[] parts = address.split(":"); + if (parts.length != 6) { + return null; + } + + final byte[] mac = new byte[6]; + try { + for (int i = 0; i < 6; i++) { + mac[i] = (byte) Integer.parseInt(parts[i], 16); + } + } catch (final NumberFormatException notAnAddress) { + return null; + } + return mac; + } +} 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 98dd94df..e55688e6 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 @@ -62,9 +62,18 @@ public final class NearbyTagIndex { */ public static final class Match { private final String beaconId; - private final int keyIndex; + /** + * Where this address came from, or null when that is not known. + * + *

Null for an address read back from {@link DerivedAddressStore}: the index a + * secondary key is reported at depends on where the deriving range began, so it is an + * artefact of how the work was split rather than a fact worth keeping. It travels on as + * a hint, and a missing hint simply costs Python one wide check. + */ + @Nullable + private final Integer keyIndex; - Match(final String beaconId, final int keyIndex) { + Match(final String beaconId, @Nullable final Integer keyIndex) { this.beaconId = beaconId; this.keyIndex = keyIndex; } @@ -73,7 +82,8 @@ public String getBeaconId() { return this.beaconId; } - public int getKeyIndex() { + @Nullable + public Integer getKeyIndex() { return this.keyIndex; } } @@ -99,12 +109,30 @@ public void rebuild( final Map accessoryJsonByBeaconId, final AccessoryMacResolver resolver, final long nowMs) { + this.rebuild(accessoryJsonByBeaconId, resolver, nowMs, null); + } + + /** + * As {@link #rebuild(Map, AccessoryMacResolver, long)}, keeping what it derives in {@code + * store} and deriving only what is missing from it. + * + * @param store where derived addresses are kept across launches, or null to derive + * everything every time, which is what a test without a filesystem wants. + */ + public void rebuild( + final Map accessoryJsonByBeaconId, + final AccessoryMacResolver resolver, + final long nowMs, + @Nullable final DerivedAddressStore store) { final Map rebuilt = new HashMap<>(); 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 Map candidates = resolver.currentMacAddresses(entry.getValue()); + final Map candidates = + store == null + ? resolver.currentMacAddresses(entry.getValue()) + : addressesFor(entry.getKey(), entry.getValue(), resolver, store); // **Null is a documented answer, not a broken one, and it must not stop the loop.** // The interface permits it for an accessory the resolver cannot read, and for one @@ -122,7 +150,9 @@ public void rebuild( } for (final Map.Entry candidate : candidates.entrySet()) { - if (candidate.getKey() != null && candidate.getValue() != null) { + // A null value is an address whose index is not known, which is ordinary for one + // recovered from the store. Only a null address is useless. + if (candidate.getKey() != 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. @@ -137,6 +167,81 @@ public void rebuild( this.builtAtMs = nowMs; } + /** + * How wide a stored range is allowed to grow before it is started over. + * + *

The window creeps upward with the clock, about a hundred indices a day, so the union of + * everything ever derived grows without limit for a tag that is kept for years. At this width + * it is roughly a year of history and a few megabytes; past it, the oldest part is certainly + * dead and is not worth carrying. Starting over costs one derivation of the current window. + */ + static final int MAX_STORED_INDICES = 40_000; + + /** + * The addresses for one tag, derived only where the stored copy does not already have them. + * + *

Extended rather than replaced, and only while it stays contiguous. The stored + * range and the wanted window normally overlap, because the window moves by one index every + * fifteen minutes. When they do not overlap at all the app has not run for a very long time, + * and the honest answer is a fresh derivation: recording a range as covered when the middle + * of it was never derived would mean silently never looking there again. + */ + private static Map addressesFor( + final String beaconId, + final String accessoryJson, + final AccessoryMacResolver resolver, + final DerivedAddressStore store) { + + final AccessoryMacResolver.IndexRange window = resolver.candidateWindow(accessoryJson); + if (window == null || window.width() == 0) { + // Unreadable, or an accessory with no rolling keys at all. Ask the way that has + // always answered for those, and keep nothing. + return resolver.currentMacAddresses(accessoryJson); + } + + final DerivedAddressStore.Derived held = store.load(beaconId); + + if (held != null && held.covers(window.getLo(), window.getHi())) { + // The whole point: nothing is derived at all, on the launch where deriving is most + // expensive because everything else is starting up at the same time. + Log.d(TAG, "Reusing " + held.getAddresses().size() + " stored address(es) for" + + " beaconId=" + beaconId + " covering " + held.getLo() + ".." + held.getHi()); + return held.getAddresses(); + } + + final boolean extendable = held != null + && held.getLo() <= window.getHi() + 1 + && window.getLo() <= held.getHi() + 1 + && Math.max(held.getHi(), window.getHi()) + - Math.min(held.getLo(), window.getLo()) < MAX_STORED_INDICES; + + final Map addresses = + extendable ? new HashMap<>(held.getAddresses()) : new HashMap<>(); + + final int haveLo = extendable ? held.getLo() : Integer.MAX_VALUE; + final int haveHi = extendable ? held.getHi() : Integer.MIN_VALUE; + + if (!extendable) { + addresses.putAll(resolver.addressesBetween( + accessoryJson, window.getLo(), window.getHi())); + } else { + if (window.getLo() < haveLo) { + addresses.putAll(resolver.addressesBetween( + accessoryJson, window.getLo(), haveLo - 1)); + } + if (window.getHi() > haveHi) { + addresses.putAll(resolver.addressesBetween( + accessoryJson, haveHi + 1, window.getHi())); + } + } + + final int storedLo = extendable ? Math.min(haveLo, window.getLo()) : window.getLo(); + final int storedHi = extendable ? Math.max(haveHi, window.getHi()) : window.getHi(); + + store.save(beaconId, storedLo, storedHi, addresses); + return addresses; + } + /** The tag this address belongs to and the index it came from, or null if it is not ours. */ @Nullable public Match matchFor(@Nullable final String scannedAddress) { 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 d3de828e..7d8997ba 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 @@ -1,5 +1,7 @@ package dev.wander.android.opentagviewer.ble; +import androidx.annotation.Nullable; + import lombok.AllArgsConstructor; import lombok.Getter; @@ -34,7 +36,8 @@ public final class NearbyTagSighting { * index instead of re-deriving a 48-hour window: three key derivations instead of about * 1150. */ - private final int keyIndex; + @Nullable + private final Integer keyIndex; /** Signal strength in dBm. Negative; closer to zero is nearer. */ private final int rssi; 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 b940b3eb..b8620c2f 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 @@ -110,6 +110,17 @@ public interface SightingListener { * advertisement that arrives while the first is still running. */ private final AtomicBoolean indexRebuildInFlight = new AtomicBoolean(false); + /** + * Where derived addresses are kept between launches, once a scan has supplied a context. + * + *

Set in {@link #watch} rather than injected, because it needs the app's files directory + * and this class is constructed by screens and a service that have no reason to know about + * one. Null until then, which is what the JVM tests run against: they exercise the matching, + * and a test that has no filesystem should derive rather than persist. + */ + @Nullable + private volatile DerivedAddressStore derivedAddresses; + /** * How hard the radio listens. * @@ -178,8 +189,15 @@ public Observable watch( // Blocking, one interpreter start per tag - hence subscribeOn(io) below, and hence // the index rather than resolving per scan result. See NearbyTagIndex. + if (this.derivedAddresses == null) { + this.derivedAddresses = + new DerivedAddressStore(context.getApplicationContext().getFilesDir()); + } + this.derivedAddresses.forgetAllExcept(accessoryJsonByBeaconId.keySet()); + if (this.index.isStale(this.clock.nowMs())) { - this.index.rebuild(accessoryJsonByBeaconId, this.macResolver, this.clock.nowMs()); + this.index.rebuild(accessoryJsonByBeaconId, this.macResolver, this.clock.nowMs(), + this.derivedAddresses); Log.d(TAG, "Watching " + this.index.size() + " candidate address(es) for " + accessoryJsonByBeaconId.size() + " tag(s)"); } @@ -306,7 +324,8 @@ private void maybeRebuildIndex(final Map accessoryJsonByBeaconId } Schedulers.io().scheduleDirect(() -> { try { - this.index.rebuild(accessoryJsonByBeaconId, this.macResolver, this.clock.nowMs()); + this.index.rebuild(accessoryJsonByBeaconId, this.macResolver, this.clock.nowMs(), + this.derivedAddresses); Log.d(TAG, "Rebuilt the nearby index mid-watch: " + this.index.size() + " candidate address(es) for " + accessoryJsonByBeaconId.size() + " tag(s)"); 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 2f0bdceb..c92b22dc 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,5 +1,7 @@ package dev.wander.android.opentagviewer.python; +import androidx.annotation.Nullable; + import java.util.Map; /** @@ -65,6 +67,67 @@ public interface AccessoryMacResolver { * only ever care about the candidate set - keeps compiling. {@link ChaquopyAccessoryMacResolver} * overrides it for real. */ + /** + * The inclusive key index range worth scanning for this accessory right now, or null if it + * cannot be read. + * + *

Separate from {@link #currentMacAddresses} because it costs nothing: it says which part + * of the range matters without deriving a single address. A caller that keeps what it + * derived last time needs exactly this to work out what it is missing, and not re-deriving + * what it already holds is the whole reason for keeping it. + * + *

Defaulted to null so a test double that only cares about candidate addresses keeps + * compiling, same as {@link #recordSeen}. + */ + @Nullable + default IndexRange candidateWindow(String accessoryJson) { + return null; + } + + /** + * The addresses this accessory can advertise across an inclusive key index range. + * + *

Takes the range rather than choosing one, so a caller widening its search downward can + * name the piece below whatever {@link #currentMacAddresses} would have picked. + * + *

The address set is stable; the index attached to it is not. A secondary key is + * reported at the first index the call's own range reaches, so the same address comes back + * against a different index depending on where the range started. Primary keys do not move. + * See {@code main.addressesBetween}. + */ + default Map addressesBetween(String accessoryJson, int lo, int hi) { + return Map.of(); + } + + /** An inclusive range of key indices. */ + final class IndexRange { + private final int lo; + private final int hi; + + public IndexRange(final int lo, final int hi) { + this.lo = lo; + this.hi = hi; + } + + public int getLo() { + return this.lo; + } + + public int getHi() { + return this.hi; + } + + /** How many indices this covers, which is what the derivation is charged by. */ + public int width() { + return this.hi < this.lo ? 0 : this.hi - this.lo + 1; + } + + @Override + public String toString() { + return this.lo + ".." + this.hi; + } + } + 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 70e02906..d1975d23 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 @@ -2,6 +2,8 @@ import android.util.Log; +import androidx.annotation.Nullable; + import com.chaquo.python.PyObject; import com.chaquo.python.Python; @@ -56,6 +58,59 @@ public Map currentMacAddresses(final String accessoryJson) { } } + @Override + @Nullable + public IndexRange candidateWindow(final String accessoryJson) { + if (accessoryJson == null || accessoryJson.isEmpty()) { + return null; + } + + try { + final var module = Python.getInstance().getModule(MODULE_MAIN); + final PyObject returned = module.callAttr("candidateWindow", accessoryJson); + + if (returned == null) { + return null; + } + + final Map window = returned.asMap(); + return new IndexRange( + window.get(PyObject.fromJava("lo")).toInt(), + window.get(PyObject.fromJava("hi")).toInt()); + } catch (final Exception e) { + Log.w(TAG, "candidateWindow failed", e); + return null; + } + } + + @Override + public Map addressesBetween( + final String accessoryJson, final int lo, final int hi) { + if (accessoryJson == null || accessoryJson.isEmpty() || hi < lo) { + return Collections.emptyMap(); + } + + try { + final var module = Python.getInstance().getModule(MODULE_MAIN); + final PyObject returned = + module.callAttr("addressesBetween", accessoryJson, lo, hi); + + if (returned == null) { + Log.w(TAG, "addressesBetween returned None (check python logs for details)"); + return Collections.emptyMap(); + } + + final Map derived = new HashMap<>(); + for (final Map.Entry entry : returned.asMap().entrySet()) { + derived.put(entry.getKey().toString(), entry.getValue().toInt()); + } + return derived; + } catch (final Exception e) { + Log.w(TAG, "addressesBetween failed", e); + return Collections.emptyMap(); + } + } + @Override public String recordSeen( final String accessoryJson, final String mac, final long seenAtUnixMs, diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/DerivedAddressStoreTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/DerivedAddressStoreTest.java new file mode 100644 index 00000000..708415c4 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/DerivedAddressStoreTest.java @@ -0,0 +1,201 @@ +package dev.wander.android.opentagviewer.ble; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * What the derived-address cache has to guarantee, which is less than it looks. + * + *

It is allowed to lose everything at any time: a miss costs a derivation, which is what + * would have happened without it. What it must never do is claim to hold a range it does not, + * because nothing would ever go back and derive the part that was silently missing. + */ +public class DerivedAddressStoreTest { + + private static final String BEACON = "ABCDEF01-2345-6789-ABCD-EF0123456789"; + + @Rule + public TemporaryFolder files = new TemporaryFolder(); + + private DerivedAddressStore store() { + return new DerivedAddressStore(this.files.getRoot()); + } + + private static Map addresses(final String... macs) { + final Map out = new HashMap<>(); + for (int i = 0; i < macs.length; i++) { + out.put(macs[i], i); + } + return out; + } + + @Test + public void nothingIsHeldForATagThatWasNeverWritten() { + assertNull(this.store().load(BEACON)); + } + + @Test + public void whatWasWrittenComesBack() { + final DerivedAddressStore store = this.store(); + store.save(BEACON, 100, 200, addresses("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02")); + + final DerivedAddressStore.Derived held = store.load(BEACON); + + assertNotNull(held); + assertEquals(100, held.getLo()); + assertEquals(200, held.getHi()); + assertEquals(Set.of("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"), + held.getAddresses().keySet()); + } + + /** + * The index is deliberately dropped, so it must read back as absent rather than as some + * plausible-looking number a caller might trust. See {@link DerivedAddressStore}. + */ + @Test + public void theIndexIsNotKeptAndReadsBackAsUnknown() { + final DerivedAddressStore store = this.store(); + store.save(BEACON, 0, 10, addresses("AA:BB:CC:DD:EE:01")); + + final DerivedAddressStore.Derived held = store.load(BEACON); + + assertNotNull(held); + assertTrue(held.getAddresses().containsKey("AA:BB:CC:DD:EE:01")); + assertNull(held.getAddresses().get("AA:BB:CC:DD:EE:01")); + } + + @Test + public void coverageIsReportedForTheStoredRangeOnly() { + final DerivedAddressStore store = this.store(); + store.save(BEACON, 100, 200, addresses("AA:BB:CC:DD:EE:01")); + + final DerivedAddressStore.Derived held = store.load(BEACON); + + assertNotNull(held); + assertTrue(held.covers(120, 180)); + assertTrue(held.covers(100, 200)); + assertFalse("a range starting below what was derived is not covered", held.covers(99, 200)); + assertFalse("a range ending above what was derived is not covered", held.covers(100, 201)); + } + + @Test + public void writingAgainReplacesWhatWasThere() { + final DerivedAddressStore store = this.store(); + store.save(BEACON, 100, 200, addresses("AA:BB:CC:DD:EE:01")); + store.save(BEACON, 50, 200, addresses("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02")); + + final DerivedAddressStore.Derived held = store.load(BEACON); + + assertNotNull(held); + assertEquals(50, held.getLo()); + assertEquals(2, held.getAddresses().size()); + } + + @Test + public void twoTagsDoNotShareAFile() { + final DerivedAddressStore store = this.store(); + store.save(BEACON, 0, 10, addresses("AA:BB:CC:DD:EE:01")); + store.save("OTHER-TAG", 0, 10, addresses("AA:BB:CC:DD:EE:02")); + + assertEquals(Set.of("AA:BB:CC:DD:EE:01"), store.load(BEACON).getAddresses().keySet()); + assertEquals(Set.of("AA:BB:CC:DD:EE:02"), store.load("OTHER-TAG").getAddresses().keySet()); + } + + /** + * A half-written file must read as nothing rather than as a shorter range. Reading it as a + * shorter range is the one failure that would not announce itself: the missing part would be + * derived again on every launch, and nothing would ever say why. + */ + @Test + public void aTruncatedFileIsTreatedAsNothingHeld() throws IOException { + final DerivedAddressStore store = this.store(); + store.save(BEACON, 100, 200, addresses("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02")); + + final File file = new File(new File(this.files.getRoot(), "derived-addresses"), + BEACON + ".bin"); + assertTrue(file.isFile()); + + final byte[] whole = java.nio.file.Files.readAllBytes(file.toPath()); + try (FileOutputStream out = new FileOutputStream(file)) { + out.write(whole, 0, whole.length - 3); + } + + assertNull(store.load(BEACON)); + } + + @Test + public void aFileFromAnotherFormatIsDiscarded() throws IOException { + final DerivedAddressStore store = this.store(); + store.save(BEACON, 100, 200, addresses("AA:BB:CC:DD:EE:01")); + + final File file = new File(new File(this.files.getRoot(), "derived-addresses"), + BEACON + ".bin"); + final byte[] whole = java.nio.file.Files.readAllBytes(file.toPath()); + whole[3] = (byte) 99; + try (FileOutputStream out = new FileOutputStream(file)) { + out.write(whole); + } + + assertNull(store.load(BEACON)); + } + + @Test + public void tagsTheUserNoLongerHasAreForgotten() { + final DerivedAddressStore store = this.store(); + store.save(BEACON, 0, 10, addresses("AA:BB:CC:DD:EE:01")); + store.save("GONE-FROM-THE-ACCOUNT", 0, 10, addresses("AA:BB:CC:DD:EE:02")); + + store.forgetAllExcept(Set.of(BEACON)); + + assertNotNull(store.load(BEACON)); + assertNull(store.load("GONE-FROM-THE-ACCOUNT")); + } + + /** + * Beacon ids arrive from an imported file, and this builds a path with one. A separator in + * the id must not put the file somewhere else, and must still round-trip. + */ + @Test + public void anIdThatLooksLikeAPathStaysInsideTheDirectory() { + final DerivedAddressStore store = this.store(); + store.save("../../etc/passwd", 0, 10, addresses("AA:BB:CC:DD:EE:01")); + + final File directory = new File(this.files.getRoot(), "derived-addresses"); + final File[] written = directory.listFiles(); + + assertNotNull(written); + assertEquals(1, written.length); + assertFalse(written[0].getName().contains("/")); + assertNotNull(store.load("../../etc/passwd")); + } + + @Test + public void anAddressThatIsNotAnAddressIsDroppedRatherThanCorrupting() { + final DerivedAddressStore store = this.store(); + + final Map mixed = new HashMap<>(); + mixed.put("AA:BB:CC:DD:EE:01", 1); + mixed.put("not an address", 2); + store.save(BEACON, 0, 10, mixed); + + final DerivedAddressStore.Derived held = store.load(BEACON); + + // The count in the header claimed two; only one was written. Reading must not invent a + // second one out of whatever followed. + assertNull("a short file is not a partly-good file", held); + } +} 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 f1d74ad7..c1746057 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 @@ -186,8 +186,8 @@ public void eachAddressRemembersTheIndexItWasDerivedAt() { final NearbyTagIndex index = new NearbyTagIndex(); index.rebuild(Map.of(KEYS, "j"), resolverFor(Map.of("j", byMac)), 0L); - assertEquals(6221, index.matchFor("AA:AA:AA:AA:AA:01").getKeyIndex()); - assertEquals(6222, index.matchFor("AA:AA:AA:AA:AA:02").getKeyIndex()); + assertEquals(Integer.valueOf(6221), index.matchFor("AA:AA:AA:AA:AA:01").getKeyIndex()); + assertEquals(Integer.valueOf(6222), index.matchFor("AA:AA:AA:AA:AA:02").getKeyIndex()); } @Test From aade994edc256b1f6486a076196ef0e7fa9902e2 Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:43:59 +0200 Subject: [PATCH 47/61] Look further back for a tag nobody has heard, a chunk at a time A tag is searched for at addresses worked out from its stored alignment, extrapolated forward at one index every fifteen minutes. That is right for a tag that has been running, which is the ordinary case even after months out of contact, because the index follows the tag's own clock and not the network. It is wrong for one whose true index has drifted away from the extrapolation: a tag that spent time without power, or whose alignment was pushed too high. Such a tag is searched for at addresses it will never use, and from the outside looks exactly like a tag that is gone. So the search widens on its own, downward, five hundred indices at a time, at most one tag and one chunk a minute, and only for tags not heard in the last ten minutes. A hundred days is covered in about twenty minutes. Deliberately not a "search harder" button. That would put the question to the person least able to answer it: whether a tag is missing or merely out of step is precisely what they opened the app to find out. It stays out of the first two minutes after a watch starts, and that is the one rule it has. Deriving was measured at two to three seconds per thousand indices on an idle phone and sixty to a hundred while the app was starting up and competing with itself, so when it runs matters far more than how much it does. Progress needs no state of its own: the store already records the range it holds, so the bottom of that range is how far the search has got. A restart resumes where it left off, and a tag already at the floor costs a file read. Also fixes a case this turned up. The index was starting over whenever the stored range and the current window no longer touched, which happens after the app has been closed for about four days - so a range widened over hours was thrown away exactly for the tag the widening exists for. Deriving from the top of what is held up to the top of the window covers the gap and the window together, so the result is still contiguous and nothing is recorded as covered that was never derived. Only the total width can rule out extending now. --- .../opentagviewer/ble/NearbyTagIndex.java | 17 +- .../opentagviewer/ble/NearbyTagWatcher.java | 66 +++++ .../opentagviewer/ble/WideningSearch.java | 210 +++++++++++++++ .../ble/NearbyTagIndexStoreTest.java | 184 +++++++++++++ .../opentagviewer/ble/WideningSearchTest.java | 255 ++++++++++++++++++ 5 files changed, 725 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/WideningSearch.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexStoreTest.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/ble/WideningSearchTest.java 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 e55688e6..694ceb16 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 @@ -180,11 +180,14 @@ public void rebuild( /** * The addresses for one tag, derived only where the stored copy does not already have them. * - *

Extended rather than replaced, and only while it stays contiguous. The stored - * range and the wanted window normally overlap, because the window moves by one index every - * fifteen minutes. When they do not overlap at all the app has not run for a very long time, - * and the honest answer is a fresh derivation: recording a range as covered when the middle - * of it was never derived would mean silently never looking there again. + *

Extended rather than replaced, and a gap in between is derived rather than skipped. + * The stored range and the wanted window normally overlap, since the window moves by one + * index every fifteen minutes. When they do not, the app has simply not been opened for a + * few days, and deriving from the top of what is held up to the top of the window covers the + * gap and the window together - so the result is contiguous and nothing is recorded as + * covered that was never derived. Requiring them to touch, and starting over when they did + * not, threw away a range that had been widened over hours because somebody left the app + * closed for four days, which is exactly the case the widening exists for. */ private static Map addressesFor( final String beaconId, @@ -209,9 +212,9 @@ private static Map addressesFor( return held.getAddresses(); } + // Only the total width can rule out extending: everything else is a gap, and a gap is + // derived along with the window rather than being a reason to discard what is held. final boolean extendable = held != null - && held.getLo() <= window.getHi() + 1 - && window.getLo() <= held.getHi() + 1 && Math.max(held.getHi(), window.getHi()) - Math.min(held.getLo(), window.getLo()) < MAX_STORED_INDICES; 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 b8620c2f..902353b8 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 @@ -121,6 +121,23 @@ public interface SightingListener { @Nullable private volatile DerivedAddressStore derivedAddresses; + /** + * Looks further back for tags that are not turning up. Null until {@link #watch} supplies a + * context, for the same reason as {@link #derivedAddresses}. + */ + @Nullable + private volatile WideningSearch wideningSearch; + + /** + * When each of our tags was last heard, which is what decides who is worth widening for. + * + *

Written on the scan callback thread and read on an Rx io thread, hence the concurrent + * map. Not persisted: after a restart every tag reads as never heard, which widens for all + * of them until they turn up - the right way round, since a restart is also when the index + * knows least. + */ + private final Map lastHeardMs = new ConcurrentHashMap<>(); + /** * How hard the radio listens. * @@ -195,6 +212,11 @@ public Observable watch( } this.derivedAddresses.forgetAllExcept(accessoryJsonByBeaconId.keySet()); + if (this.wideningSearch == null) { + this.wideningSearch = new WideningSearch(this.macResolver, this.derivedAddresses); + } + this.wideningSearch.started(this.clock.nowMs()); + if (this.index.isStale(this.clock.nowMs())) { this.index.rebuild(accessoryJsonByBeaconId, this.macResolver, this.clock.nowMs(), this.derivedAddresses); @@ -220,6 +242,7 @@ public void onScanResult(final int callbackType, final ScanResult result) { // index is stale, our own tag's advertisements are exactly the ones that // no longer match, so they cannot be the trigger. maybeRebuildIndex(accessoryJsonByBeaconId); + maybeWidenSearch(accessoryJsonByBeaconId); final NearbyTagSighting sighting = sightingFrom(result); if (sighting == null) { @@ -335,6 +358,44 @@ private void maybeRebuildIndex(final Map accessoryJsonByBeaconId }); } + /** + * Looks one chunk further back for a tag nobody has heard, when a round is due. + * + *

Driven by arriving advertisements for the same reason {@link #maybeRebuildIndex} is: + * it is the one signal this class reliably gets, and it costs nothing on the scan thread + * because everything expensive is handed to {@link Schedulers#io()} behind the same + * single-flight guard. Most of those advertisements belong to strangers, which is fine - + * they are a clock, not evidence. + * + *

The index is rebuilt straight after a round that derived something, because addresses + * that are only in the store and not in the index match nothing. + */ + private void maybeWidenSearch(final Map accessoryJsonByBeaconId) { + final WideningSearch search = this.wideningSearch; + if (search == null || !search.isDue(this.clock.nowMs())) { + return; + } + if (!this.indexRebuildInFlight.compareAndSet(false, true)) { + return; + } + + Schedulers.io().scheduleDirect(() -> { + try { + final String widened = search.widenOne( + accessoryJsonByBeaconId, this.lastHeardMs, this.clock.nowMs()); + + if (widened != null) { + this.index.rebuild(accessoryJsonByBeaconId, this.macResolver, + this.clock.nowMs(), this.derivedAddresses); + Log.d(TAG, "Index now holds " + this.index.size() + + " candidate address(es) after widening for beaconId=" + widened); + } + } finally { + this.indexRebuildInFlight.set(false); + } + }); + } + /** * One scan result turned into a sighting, or null if it is not one of ours. * @@ -360,6 +421,11 @@ NearbyTagSighting sightingFrom(final ScanResult result) { return null; } + // Noted here rather than in the emitter, so it is recorded even for a subscriber that + // has gone away: who is worth widening for is a fact about the radio, not about who + // happens to be listening. + this.lastHeardMs.put(match.getBeaconId(), this.clock.nowMs()); + return new NearbyTagSighting(match.getBeaconId(), match.getKeyIndex(), result.getRssi(), advertisement.getBatteryLevel(), advertisement.getStatusByte(), advertisement.getState(), this.clock.nowMs()); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/WideningSearch.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/WideningSearch.java new file mode 100644 index 00000000..66fd9ded --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/WideningSearch.java @@ -0,0 +1,210 @@ +package dev.wander.android.opentagviewer.ble; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import dev.wander.android.opentagviewer.python.AccessoryMacResolver; + +/** + * Looks further back for a tag nobody has heard, a little at a time, without being asked. + * + *

The problem it exists for. The addresses worth scanning for are worked out from the + * tag's stored key alignment, extrapolated forward at one index every fifteen minutes. That is + * right for a tag that has been running, which is the ordinary case even after months out of + * contact, because the index follows the tag's own clock and not the network. It is wrong for a + * tag whose true index has drifted away from the extrapolation: one that spent time without + * power, or whose stored alignment was pushed too high. Such a tag is then searched for at + * addresses it will never use, and from the outside is indistinguishable from a tag that is + * simply gone. That is the failure this closes. + * + *

Why widening rather than a button. A manual "search harder" action would put the + * question to the person least able to answer it: they cannot tell a tag that is missing from + * one that is out of step, which is exactly what they came to the app to find out. So the search + * widens on its own, and the only thing the user ever sees is that the tag turns up. + * + *

Why a little at a time. Deriving costs about two to three seconds per thousand + * indices on an idle phone, and was measured at sixty to a hundred while the app was starting up + * and competing with itself. Covering a hundred days in one go is therefore either half a minute + * or several, depending entirely on when it is attempted. In chunks it is a fixed, small cost + * that can be spent when there is room for it, and the whole range is covered within the hour + * either way. + * + *

Progress needs no state of its own. {@link DerivedAddressStore} records the range it + * holds, so the bottom of that range is exactly how far the search has got. Restarting the app, + * or the service being killed, costs nothing and resumes where it left off. + * + *

No Android in here, so the rule is covered by a JVM test. + */ +public final class WideningSearch { + private static final String TAG = WideningSearch.class.getSimpleName(); + + /** + * How many indices one round derives. + * + *

Small enough that a single round is affordable even on a device where derivation is + * running an order of magnitude slower than measured: at the worst rate seen, a hundred + * seconds per thousand indices, this is still under a minute of work that nothing is + * waiting on. + */ + static final int CHUNK_INDICES = 500; + + /** + * How far back the search is willing to go, as indices below the top of the current window. + * + *

A hundred days at four indices an hour. Past that the balance tips: the derivation is + * still cheap in chunks, but a tag that has been out of step for longer than that is more + * likely gone than out of step, and the addresses are worth less than the space they take. + */ + static final int TARGET_INDICES = 9_600; + + /** + * How recently a tag must have been heard to be left alone. + * + *

Deliberately long. Widening a tag that is merely quiet for a minute would spend the + * derivation on the tags least in need of it, and a tag in the same room is heard many times + * inside this window. + */ + static final long HEARD_RECENTLY_MS = TimeUnit.MINUTES.toMillis(10); + + /** + * The least time between rounds. + * + *

The point is that this never competes with anything. A round a minute covers a hundred + * days in about twenty minutes, which is far quicker than the situation it is for. + */ + static final long BETWEEN_ROUNDS_MS = TimeUnit.MINUTES.toMillis(1); + + /** + * How long after the watch starts before the first round. + * + *

The measured worst case for derivation was during app startup - sixty to a hundred + * seconds per thousand indices, against two to three when idle - so the one rule this must + * follow is to stay out of that window. Everything else it does is cheap; doing it at the + * wrong moment is not. + */ + static final long WARM_UP_MS = TimeUnit.MINUTES.toMillis(2); + + private final AccessoryMacResolver resolver; + private final DerivedAddressStore store; + + private long startedAtMs = Long.MIN_VALUE; + private long lastRoundMs = Long.MIN_VALUE; + + public WideningSearch(final AccessoryMacResolver resolver, final DerivedAddressStore store) { + this.resolver = resolver; + this.store = store; + } + + /** Notes when the watch began, which is what the warm-up is measured from. */ + public void started(final long nowMs) { + this.startedAtMs = nowMs; + } + + /** Whether a round is due: past the warm-up, and not too soon after the last one. */ + public boolean isDue(final long nowMs) { + if (this.startedAtMs == Long.MIN_VALUE || nowMs - this.startedAtMs < WARM_UP_MS) { + return false; + } + return this.lastRoundMs == Long.MIN_VALUE || nowMs - this.lastRoundMs >= BETWEEN_ROUNDS_MS; + } + + /** + * Derives one chunk further back for one tag that has not been heard, if any needs it. + * + *

One tag per round rather than all of them, so the cost of a round does not depend on + * how many tags somebody owns. + * + * @param lastHeardMsByBeaconId when each tag was last heard; absent means never. + * @return the beacon whose range grew, or null if there was nothing to do. + */ + @Nullable + public String widenOne( + final Map accessoryJsonByBeaconId, + final Map lastHeardMsByBeaconId, + final long nowMs) { + + this.lastRoundMs = nowMs; + + for (final Map.Entry entry : accessoryJsonByBeaconId.entrySet()) { + final String beaconId = entry.getKey(); + + final Long lastHeard = lastHeardMsByBeaconId.get(beaconId); + if (lastHeard != null && nowMs - lastHeard < HEARD_RECENTLY_MS) { + continue; + } + + if (this.widen(beaconId, entry.getValue())) { + return beaconId; + } + } + + return null; + } + + /** True when this tag's stored range actually grew. */ + private boolean widen(final String beaconId, final String accessoryJson) { + final DerivedAddressStore.Derived held = this.store.load(beaconId); + if (held == null) { + // Nothing derived yet at all. The ordinary index rebuild creates it, and widening + // something that does not exist would race with that for no gain. + return false; + } + + final AccessoryMacResolver.IndexRange window = this.resolver.candidateWindow(accessoryJson); + if (window == null) { + return false; + } + + final int floor = Math.max(0, window.getHi() - TARGET_INDICES); + if (held.getLo() <= floor) { + // As far back as this is willing to look. Not a failure: a tag still unheard here has + // been out of step for longer than the addresses are worth keeping for. + return false; + } + + final int to = held.getLo() - 1; + final int from = Math.max(floor, held.getLo() - CHUNK_INDICES); + + final Map derived = this.resolver.addressesBetween( + accessoryJson, from, to); + if (derived == null || derived.isEmpty()) { + // An empty answer for a non-empty range means the derivation failed. Advancing the + // stored range past it anyway would record indices as covered that were never + // derived, and nothing would ever go back for them. + Log.d(TAG, "Nothing derived for beaconId=" + beaconId + " over " + from + ".." + to + + "; leaving the stored range where it is"); + return false; + } + + final Map widened = new HashMap<>(held.getAddresses()); + widened.putAll(derived); + + this.store.save(beaconId, from, held.getHi(), widened); + + Log.i(TAG, "Widened the search for beaconId=" + beaconId + " down to " + from + + " (" + widened.size() + " address(es), floor " + floor + ")"); + return true; + } + + /** The tags worth widening for right now, for a caller that wants to log or test the choice. */ + static Set notHeardRecently( + final Set beaconIds, + final Map lastHeardMsByBeaconId, + final long nowMs) { + + final Set missing = new java.util.HashSet<>(); + for (final String beaconId : beaconIds) { + final Long lastHeard = lastHeardMsByBeaconId.get(beaconId); + if (lastHeard == null || nowMs - lastHeard >= HEARD_RECENTLY_MS) { + missing.add(beaconId); + } + } + return missing; + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexStoreTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexStoreTest.java new file mode 100644 index 00000000..494a65ac --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexStoreTest.java @@ -0,0 +1,184 @@ +package dev.wander.android.opentagviewer.ble; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import androidx.annotation.Nullable; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import dev.wander.android.opentagviewer.python.AccessoryMacResolver; + +/** + * What the index derives when it already holds some of the answer. + * + *

The point of keeping derived addresses is not deriving them again, so these tests are + * mostly about what is not asked for. The one that matters most is the gap: a range held + * from an earlier session and a window that has since moved past it must end up joined, because + * the alternative - starting over - throws away hours of widening for a tag nobody has heard, + * which is the tag the widening was for. + */ +public class NearbyTagIndexStoreTest { + + private static final String BEACON = "A-TAG"; + private static final String JSON = "{\"accessory\":true}"; + + @Rule + public TemporaryFolder files = new TemporaryFolder(); + + private DerivedAddressStore store; + private RecordingResolver resolver; + private NearbyTagIndex index; + + private static final class RecordingResolver implements AccessoryMacResolver { + private final List derived = new ArrayList<>(); + private int windowLo = 9_617; + private int windowHi = 10_000; + + @Override + public Map currentMacAddresses(final String accessoryJson) { + throw new AssertionError("the store path must not fall back to currentMacAddresses"); + } + + @Override + @Nullable + public IndexRange candidateWindow(final String accessoryJson) { + return new IndexRange(this.windowLo, this.windowHi); + } + + @Override + public Map addressesBetween( + final String accessoryJson, final int lo, final int hi) { + this.derived.add(new int[]{lo, hi}); + + final Map out = new HashMap<>(); + for (int i = lo; i <= hi; i++) { + out.put(macFor(i), i); + } + return out; + } + } + + private static String macFor(final int index) { + return String.format("AA:BB:CC:%02X:%02X:%02X", + (index >> 16) & 0xFF, (index >> 8) & 0xFF, index & 0xFF); + } + + @Before + public void setUp() { + this.store = new DerivedAddressStore(this.files.getRoot()); + this.resolver = new RecordingResolver(); + this.index = new NearbyTagIndex(); + } + + private void rebuild(final long nowMs) { + this.index.rebuild(Map.of(BEACON, JSON), this.resolver, nowMs, this.store); + } + + @Test + public void anEmptyStoreDerivesTheWholeWindowAndKeepsIt() { + this.rebuild(0L); + + assertEquals(1, this.resolver.derived.size()); + assertEquals(9_617, this.resolver.derived.get(0)[0]); + assertEquals(10_000, this.resolver.derived.get(0)[1]); + + final DerivedAddressStore.Derived held = this.store.load(BEACON); + assertNotNull(held); + assertEquals(9_617, held.getLo()); + assertEquals(10_000, held.getHi()); + } + + @Test + public void aSecondRebuildAtTheSameMomentDerivesNothing() { + this.rebuild(0L); + this.resolver.derived.clear(); + + this.rebuild(1_000L); + + assertTrue("the window had not moved, so there was nothing to derive", + this.resolver.derived.isEmpty()); + assertNotNull(this.index.matchFor(macFor(9_800))); + } + + @Test + public void onlyTheIndicesTheWindowHasMovedOnToAreDerived() { + this.rebuild(0L); + this.resolver.derived.clear(); + + this.resolver.windowLo = 9_620; + this.resolver.windowHi = 10_003; + this.rebuild(1_000L); + + assertEquals(1, this.resolver.derived.size()); + assertEquals("only the three new indices at the top", 10_001, this.resolver.derived.get(0)[0]); + assertEquals(10_003, this.resolver.derived.get(0)[1]); + } + + /** + * The app left closed for a few days: the window has moved past what is held, so the two no + * longer touch. The gap must be derived along with the window, and the widened bottom kept. + */ + @Test + public void aWindowThatHasMovedPastWhatIsHeldJoinsUpRatherThanStartingOver() { + this.store.save(BEACON, 400, 10_000, Map.of(macFor(400), 400)); + + this.resolver.windowLo = 10_600; + this.resolver.windowHi = 10_983; + this.rebuild(0L); + + assertEquals(1, this.resolver.derived.size()); + assertEquals("the gap must be derived, not skipped", 10_001, this.resolver.derived.get(0)[0]); + assertEquals(10_983, this.resolver.derived.get(0)[1]); + + final DerivedAddressStore.Derived held = this.store.load(BEACON); + assertNotNull(held); + assertEquals("the widened bottom must survive", 400, held.getLo()); + assertEquals(10_983, held.getHi()); + assertTrue(held.getAddresses().containsKey(macFor(400))); + assertTrue(held.getAddresses().containsKey(macFor(10_500))); + } + + /** + * A tag that turns up again after a long absence is matched from the widened part of the + * store, which is the whole reason for keeping it. + */ + @Test + public void anAddressFromTheWidenedPartStillMatches() { + this.store.save(BEACON, 400, 10_000, Map.of(macFor(450), 450)); + + this.resolver.windowLo = 9_617; + this.resolver.windowHi = 10_000; + this.rebuild(0L); + + final NearbyTagIndex.Match match = this.index.matchFor(macFor(450)); + + assertNotNull("an address derived hours ago must still be matched", match); + assertEquals(BEACON, match.getBeaconId()); + assertNull("the stored index is not kept, so there is no hint", match.getKeyIndex()); + } + + @Test + public void aRangeGrownPastTheCapIsStartedOver() { + this.store.save(BEACON, 0, 10_000, Map.of(macFor(0), 0)); + + this.resolver.windowLo = NearbyTagIndex.MAX_STORED_INDICES + 100; + this.resolver.windowHi = NearbyTagIndex.MAX_STORED_INDICES + 483; + this.rebuild(0L); + + final DerivedAddressStore.Derived held = this.store.load(BEACON); + assertNotNull(held); + assertEquals("past the cap the oldest part is certainly dead and is dropped", + NearbyTagIndex.MAX_STORED_INDICES + 100, held.getLo()); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/WideningSearchTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/WideningSearchTest.java new file mode 100644 index 00000000..8b373a35 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/WideningSearchTest.java @@ -0,0 +1,255 @@ +package dev.wander.android.opentagviewer.ble; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import androidx.annotation.Nullable; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import dev.wander.android.opentagviewer.python.AccessoryMacResolver; + +/** + * The rule for looking further back, without a radio, a phone or a key derivation. + * + *

What matters here is not that it derives, but when it refuses to: while the app is still + * starting up, for a tag that was just heard, past the point it is willing to look, and after a + * derivation that failed. Each of those, got wrong, is either wasted battery or a range recorded + * as covered that nothing ever looked at. + */ +public class WideningSearchTest { + + private static final String NEAR = "TAG-THAT-IS-HERE"; + private static final String MISSING = "TAG-NOBODY-HAS-HEARD"; + private static final String JSON = "{\"accessory\":true}"; + + @Rule + public TemporaryFolder files = new TemporaryFolder(); + + private DerivedAddressStore store; + private RecordingResolver resolver; + private WideningSearch search; + + /** Records what it was asked for, and answers with addresses named after the range. */ + private static final class RecordingResolver implements AccessoryMacResolver { + private final List derivedRanges = new ArrayList<>(); + private int windowHi = 10_000; + private boolean deriveNothing = false; + private boolean noWindow = false; + + @Override + public Map currentMacAddresses(final String accessoryJson) { + return Map.of(); + } + + @Override + @Nullable + public IndexRange candidateWindow(final String accessoryJson) { + return this.noWindow ? null : new IndexRange(this.windowHi - 383, this.windowHi); + } + + @Override + public Map addressesBetween( + final String accessoryJson, final int lo, final int hi) { + this.derivedRanges.add(new int[]{lo, hi}); + + if (this.deriveNothing) { + return Map.of(); + } + + final Map out = new HashMap<>(); + for (int i = lo; i <= hi; i++) { + out.put(String.format("AA:BB:CC:%02X:%02X:%02X", + (i >> 16) & 0xFF, (i >> 8) & 0xFF, i & 0xFF), i); + } + return out; + } + } + + @Before + public void setUp() { + this.store = new DerivedAddressStore(this.files.getRoot()); + this.resolver = new RecordingResolver(); + this.search = new WideningSearch(this.resolver, this.store); + } + + /** A tag whose derived range starts at {@code lo} and reaches the top of the window. */ + private void alreadyDerived(final String beaconId, final int lo) { + final Map addresses = new HashMap<>(); + addresses.put("AA:BB:CC:DD:EE:FF", lo); + this.store.save(beaconId, lo, 10_000, addresses); + } + + private static Map tags(final String... beaconIds) { + final Map out = new HashMap<>(); + for (final String beaconId : beaconIds) { + out.put(beaconId, JSON); + } + return out; + } + + @Test + public void nothingIsDueBeforeTheWatchHasEvenStarted() { + assertFalse(this.search.isDue(1_000_000L)); + } + + @Test + public void nothingIsDueDuringTheWarmUp() { + this.search.started(0L); + + assertFalse("deriving during startup is the one thing this must not do", + this.search.isDue(WideningSearch.WARM_UP_MS - 1)); + } + + @Test + public void aRoundIsDueOnceTheWarmUpHasPassed() { + this.search.started(0L); + + assertTrue(this.search.isDue(WideningSearch.WARM_UP_MS)); + } + + @Test + public void roundsAreSpacedOut() { + this.search.started(0L); + final long first = WideningSearch.WARM_UP_MS; + + this.search.widenOne(tags(MISSING), Map.of(), first); + + assertFalse(this.search.isDue(first + WideningSearch.BETWEEN_ROUNDS_MS - 1)); + assertTrue(this.search.isDue(first + WideningSearch.BETWEEN_ROUNDS_MS)); + } + + @Test + public void aTagHeardJustNowIsLeftAlone() { + this.alreadyDerived(NEAR, 9_000); + + final Map heard = new HashMap<>(); + heard.put(NEAR, 500_000L); + + assertNull(this.search.widenOne(tags(NEAR), heard, 500_000L + 1000L)); + assertTrue(this.resolver.derivedRanges.isEmpty()); + } + + @Test + public void aTagNotHeardForLongEnoughIsWidened() { + this.alreadyDerived(MISSING, 9_000); + + final Map heard = new HashMap<>(); + heard.put(MISSING, 0L); + + assertEquals(MISSING, + this.search.widenOne(tags(MISSING), heard, WideningSearch.HEARD_RECENTLY_MS)); + + assertEquals(1, this.resolver.derivedRanges.size()); + assertEquals(9_000 - WideningSearch.CHUNK_INDICES, this.resolver.derivedRanges.get(0)[0]); + assertEquals(8_999, this.resolver.derivedRanges.get(0)[1]); + } + + @Test + public void aTagNeverHeardAtAllIsWidened() { + this.alreadyDerived(MISSING, 9_000); + + assertEquals(MISSING, this.search.widenOne(tags(MISSING), Map.of(), 1_000_000L)); + } + + @Test + public void theStoredRangeGrowsDownwardAndKeepsWhatItHad() { + this.alreadyDerived(MISSING, 9_000); + + this.search.widenOne(tags(MISSING), Map.of(), 1_000_000L); + + final DerivedAddressStore.Derived held = this.store.load(MISSING); + assertNotNull(held); + assertEquals(9_000 - WideningSearch.CHUNK_INDICES, held.getLo()); + assertEquals(10_000, held.getHi()); + assertTrue("what was already held must survive", + held.getAddresses().containsKey("AA:BB:CC:DD:EE:FF")); + } + + @Test + public void roundsResumeWhereTheLastOneStopped() { + this.alreadyDerived(MISSING, 9_000); + + this.search.widenOne(tags(MISSING), Map.of(), 1_000_000L); + this.search.widenOne(tags(MISSING), Map.of(), 2_000_000L); + + assertEquals(2, this.resolver.derivedRanges.size()); + assertEquals(9_000 - 2 * WideningSearch.CHUNK_INDICES, + this.resolver.derivedRanges.get(1)[0]); + assertEquals(9_000 - WideningSearch.CHUNK_INDICES - 1, + this.resolver.derivedRanges.get(1)[1]); + } + + @Test + public void itStopsAtTheFloorRatherThanRunningToZero() { + final int floor = 10_000 - WideningSearch.TARGET_INDICES; + this.alreadyDerived(MISSING, floor + 10); + + assertEquals(MISSING, this.search.widenOne(tags(MISSING), Map.of(), 1_000_000L)); + assertEquals(floor, this.store.load(MISSING).getLo()); + + assertNull("already as far back as it will look", + this.search.widenOne(tags(MISSING), Map.of(), 2_000_000L)); + } + + @Test + public void aTagWithNothingDerivedYetIsLeftToTheOrdinaryRebuild() { + assertNull(this.search.widenOne(tags(MISSING), Map.of(), 1_000_000L)); + assertTrue(this.resolver.derivedRanges.isEmpty()); + } + + @Test + public void anUnreadableAccessoryIsSkipped() { + this.alreadyDerived(MISSING, 9_000); + this.resolver.noWindow = true; + + assertNull(this.search.widenOne(tags(MISSING), Map.of(), 1_000_000L)); + } + + /** + * The failure that would not announce itself: recording indices as covered that were never + * derived means nothing ever goes back for them, and the tag stays unfindable for a reason + * no log would show. + */ + @Test + public void aFailedDerivationDoesNotAdvanceTheStoredRange() { + this.alreadyDerived(MISSING, 9_000); + this.resolver.deriveNothing = true; + + assertNull(this.search.widenOne(tags(MISSING), Map.of(), 1_000_000L)); + assertEquals(9_000, this.store.load(MISSING).getLo()); + } + + @Test + public void onlyOneTagIsWidenedPerRound() { + this.alreadyDerived(NEAR, 9_000); + this.alreadyDerived(MISSING, 9_000); + + this.search.widenOne(tags(NEAR, MISSING), Map.of(), 1_000_000L); + + assertEquals("a round must not cost more because somebody owns more tags", + 1, this.resolver.derivedRanges.size()); + } + + @Test + public void theTagsWorthWideningAreTheOnesNotHeardRecently() { + final Map heard = new HashMap<>(); + heard.put(NEAR, 1_000_000L); + heard.put(MISSING, 1_000_000L - WideningSearch.HEARD_RECENTLY_MS); + + assertEquals(java.util.Set.of(MISSING), + WideningSearch.notHeardRecently( + java.util.Set.of(NEAR, MISSING), heard, 1_000_000L)); + } +} From adacb16df6e5202d0f8a61378d46e25d07481bda Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:54:01 +0200 Subject: [PATCH 48/61] Keep a primary key's index in the store, since that one does not move A stored address came back with no index at all, so every alignment correction from one paid the wide check: about a second against the 0.02 that a hint costs. That was the right call for a secondary key, whose index is reported at wherever the deriving range began and is therefore an artefact of how the work was split. It was the wrong call for a primary key, which occurs at exactly one index and stays there. So Python now reports a secondary key's index as -1 rather than as a number that would be believed, and the store keeps what is left. An address recovered from the file arrives with an exact hint again. Not a performance detail alone. An address kept from an earlier, wider derivation is the only way a tag whose alignment is far out of step can be recognised at all, and recognising it is only half the job: without an index to confirm, recordAccessorySeen would fall back to searching a 48 hour window around the stored alignment, which by definition does not contain the tag. It would have matched the address, shown the tag as nearby, and never repaired the alignment - so ringing, which uses the narrow window, would have gone on failing. --- .../ble/DerivedAddressStore.java | 38 +++++++++++------ .../python/ChaquopyAccessoryMacResolver.java | 7 +++- app/src/main/python/main.py | 28 +++++++++---- .../ble/DerivedAddressStoreTest.java | 26 ++++++++++-- .../ble/NearbyTagIndexStoreTest.java | 5 ++- app/src/test/python/test_main.py | 41 ++++++++++++++----- 6 files changed, 107 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/DerivedAddressStore.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/DerivedAddressStore.java index dbde9f0e..4ac7f840 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ble/DerivedAddressStore.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/DerivedAddressStore.java @@ -30,14 +30,15 @@ * itself, which is exactly when the index was being rebuilt. Paying it once per tag instead of * once per launch is what makes a range wide enough for a long-missing tag affordable at all. * - *

What is deliberately not kept: the index each address was derived at. That number is - * not stable. A secondary key is reported at whatever index the deriving call's own range began - * at, so the same address comes back against a different index depending on how the range - * happened to be split. Storing it would preserve an artefact of how the work was divided rather - * than a fact about the tag. The index is still useful as a hint, so it stays in memory for the - * window derived this session and is simply absent for addresses recovered from the file. A - * missing hint costs one wide check inside Python, which is what every check cost before hints - * existed. + *

The index is kept for primary keys and dropped for secondary ones. A primary key + * occurs at exactly one index and stays there, so it is worth keeping: an address recovered from + * this file arrives with an exact hint, and confirming an alignment then costs three key + * derivations rather than a search of a 48 hour window - measured at 0.02 seconds against about + * one. A secondary key is different. It covers 96 consecutive indices and is reported at whatever + * index the deriving call's own range began at, so the same address comes back against a + * different number depending on how the work was split. That is an artefact of the split rather + * than a fact about the tag, so it is stored as unknown and read back as no hint at all, which + * costs the wide check that every check cost before hints existed. * *

A cache in the file sense too: losing it costs time and never correctness, so it lives in * the app's files directory rather than in the database, and may be deleted at any point. @@ -46,7 +47,16 @@ public final class DerivedAddressStore { private static final String TAG = DerivedAddressStore.class.getSimpleName(); /** Bumped when the layout below changes, so an older file is discarded rather than misread. */ - private static final int FORMAT_VERSION = 1; + private static final int FORMAT_VERSION = 2; + + /** + * The stored index for an address whose index means nothing. + * + *

Matches {@code main._INDEX_UNKNOWN}. A secondary key is reported at whatever index the + * deriving call's range began at, so it is not a fact about the tag and must not be read + * back as one. + */ + private static final int INDEX_UNKNOWN = -1; private static final String DIRECTORY = "derived-addresses"; @@ -78,7 +88,7 @@ public int getHi() { return this.hi; } - /** Address to the index it was derived at, where that is known, and null where it is not. */ + /** Address to the index it was derived at, or null where that index means nothing. */ public Map getAddresses() { return this.addresses; } @@ -117,7 +127,8 @@ public Derived load(final String beaconId) { final byte[] mac = new byte[6]; for (int i = 0; i < count; i++) { in.readFully(mac); - addresses.put(formatMac(mac), null); + final int index = in.readInt(); + addresses.put(formatMac(mac), index == INDEX_UNKNOWN ? null : index); } return new Derived(lo, hi, addresses); @@ -153,10 +164,11 @@ public void save(final String beaconId, final int lo, final int hi, out.writeInt(hi); out.writeInt(addresses.size()); - for (final String address : addresses.keySet()) { - final byte[] mac = parseMac(address); + for (final Map.Entry entry : addresses.entrySet()) { + final byte[] mac = parseMac(entry.getKey()); if (mac != null) { out.write(mac); + out.writeInt(entry.getValue() == null ? INDEX_UNKNOWN : entry.getValue()); } } } catch (final IOException couldNotWrite) { 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 d1975d23..93a06832 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 @@ -102,7 +102,12 @@ public Map addressesBetween( final Map derived = new HashMap<>(); for (final Map.Entry entry : returned.asMap().entrySet()) { - derived.put(entry.getKey().toString(), entry.getValue().toInt()); + final int index = entry.getValue().toInt(); + // Python reports a secondary key's index as -1, because the number it would + // otherwise give is where the search began rather than where the tag is. Carried + // on as no hint at all: a wrong hint costs a check that fails and then the wide + // search anyway, which is strictly worse than not guessing. + derived.put(entry.getKey().toString(), index < 0 ? null : index); } return derived; } catch (final Exception e) { diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index f91b8910..b3b3ec1a 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -1090,6 +1090,12 @@ def accessoryFromJson(accessoryJson: str) -> StoredAccessory: _MAC_CANDIDATE_MAX_INDICES = 1000 +#: What `addressesBetween` reports instead of an index it cannot vouch for. Not None, because +#: the mapping crosses to Java as a plain map and a null value there is indistinguishable from +#: an address that was never derived at all. +_INDEX_UNKNOWN = -1 + + def candidateWindow(accessoryJson: str): """The key index range worth scanning for this accessory right now, without deriving it. @@ -1133,14 +1139,18 @@ def addressesBetween(accessoryJson: str, lo: int, hi: int): range into pieces and joining the results yields exactly the same set as asking for it whole, which is what lets a caller widen its search a piece at a time. - **The index attached to an address is not pure, and must not be treated as though it - were.** `keys_between` de-duplicates, and a secondary key covers 96 consecutive primary - indices, so it is reported at the first index the *call's own* range happens to reach: - ask for 19100..19160 and it comes back at 19100, ask for 19131..19160 and the same address - comes back at 19131. Primary keys occur at exactly one index and do not move. This costs - nothing downstream because a secondary match is already only a hint - `recordAccessorySeen` - verifies it and refuses to align on one - but a caller that stored the pair and later - trusted the index as exact would be trusting an artefact of where it started looking. + **A secondary key's index is reported as -1 rather than as a number that would be + believed.** `keys_between` de-duplicates, and a secondary key covers 96 consecutive primary + indices, so it comes back at the first index the *call's own* range happens to reach: ask + for 19100..19160 and it is 19100, ask for 19131..19160 and the same address is 19131. That + is an artefact of where the search started, not a fact about the tag, and a caller storing + it would later read it as exact. A primary key occurs at exactly one index and does not + move, so its index is given as it is. + + That distinction is what lets an address kept from an earlier, wider derivation still repair + an alignment months out of step: the sighting arrives with an exact index, and + `recordAccessorySeen` confirms it with three derivations instead of searching a window that, + by definition, does not contain it. Deliberately takes the range rather than working it out. A caller widening its search a piece at a time needs to say which piece, and a function that decided for itself could not @@ -1156,7 +1166,7 @@ def addressesBetween(accessoryJson: str, lo: int, hi: int): started = time.perf_counter() derived = { - key.mac_address: index + key.mac_address: (index if key.key_type == KeyPairType.PRIMARY else _INDEX_UNKNOWN) for index, key in accessory.keys_between(max(0, lo), hi) } _reportDerivationCost(hi - max(0, lo) + 1, derived, started) diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/DerivedAddressStoreTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/DerivedAddressStoreTest.java index 708415c4..62b3e4b2 100644 --- a/app/src/test/java/dev/wander/android/opentagviewer/ble/DerivedAddressStoreTest.java +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/DerivedAddressStoreTest.java @@ -62,14 +62,32 @@ public void whatWasWrittenComesBack() { held.getAddresses().keySet()); } + /** A primary key sits at one index forever, so its hint survives the round trip. */ + @Test + public void aKnownIndexComesBackExactly() { + final DerivedAddressStore store = this.store(); + + final Map known = new HashMap<>(); + known.put("AA:BB:CC:DD:EE:01", 7_412); + store.save(BEACON, 0, 10_000, known); + + final DerivedAddressStore.Derived held = store.load(BEACON); + + assertNotNull(held); + assertEquals(Integer.valueOf(7_412), held.getAddresses().get("AA:BB:CC:DD:EE:01")); + } + /** - * The index is deliberately dropped, so it must read back as absent rather than as some - * plausible-looking number a caller might trust. See {@link DerivedAddressStore}. + * An index that meant nothing when it was written must read back as absent rather than as + * some plausible-looking number a caller might trust. See {@link DerivedAddressStore}. */ @Test - public void theIndexIsNotKeptAndReadsBackAsUnknown() { + public void anUnknownIndexReadsBackAsNoHint() { final DerivedAddressStore store = this.store(); - store.save(BEACON, 0, 10, addresses("AA:BB:CC:DD:EE:01")); + + final Map unknown = new HashMap<>(); + unknown.put("AA:BB:CC:DD:EE:01", null); + store.save(BEACON, 0, 10, unknown); final DerivedAddressStore.Derived held = store.load(BEACON); diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexStoreTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexStoreTest.java index 494a65ac..8bff05a5 100644 --- a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexStoreTest.java +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagIndexStoreTest.java @@ -165,7 +165,10 @@ public void anAddressFromTheWidenedPartStillMatches() { assertNotNull("an address derived hours ago must still be matched", match); assertEquals(BEACON, match.getBeaconId()); - assertNull("the stored index is not kept, so there is no hint", match.getKeyIndex()); + // A primary key sits at one index forever, so the stored index is an exact hint. That + // is what lets a tag found this way confirm its alignment with three derivations + // instead of a search of a window that, by definition, does not contain it. + assertEquals(Integer.valueOf(450), match.getKeyIndex()); } @Test diff --git a/app/src/test/python/test_main.py b/app/src/test/python/test_main.py index 532aaba3..07c78980 100644 --- a/app/src/test/python/test_main.py +++ b/app/src/test/python/test_main.py @@ -1604,7 +1604,9 @@ def test_addresses_between_covers_exactly_the_requested_range(): assert derived is not None assert derived - assert set(derived.values()) <= set(range(19100, 19151)) + # -1 for the secondary keys, whose index would be an artefact of where the range began. + assert set(derived.values()) <= set(range(19100, 19151)) | {main._INDEX_UNKNOWN} + assert any(index != main._INDEX_UNKNOWN for index in derived.values()) def test_addresses_between_is_stable_across_calls(): @@ -1636,24 +1638,43 @@ def test_addresses_between_pieces_join_up_into_the_whole_set(): assert set(joined) == set(whole) -def test_a_secondary_key_is_reported_at_wherever_the_range_started(): +def test_a_secondary_key_reports_no_index_rather_than_a_moving_one(): """The address set is pure; the index attached to it is not, for secondary keys. - A secondary key covers 96 primary indices and `keys_between` de-duplicates, so it is - reported at the first index the call's own range reaches. Asserted rather than merely - documented because a caller storing the pair and later trusting the index as exact would - be trusting an artefact of where it started looking - see `addressesBetween`. + A secondary key covers 96 primary indices and `keys_between` de-duplicates, so it would + otherwise be reported at the first index the call's own range reaches - 19100 when asked + for 19100..19160 and 19131 for the same address when asked for 19131..19160. That is where + the search started, not a fact about the tag, so it is reported as unknown instead. Pinned + down because a caller that stored such a pair would later read it as exact. """ accessory = json.dumps(_freshly_aligned_accessory()) whole = main.addressesBetween(accessory, 19100, 19160) upper = main.addressesBetween(accessory, 19131, 19160) - moved = {mac for mac in set(whole) & set(upper) if whole[mac] != upper[mac]} + unknown = {mac for mac, index in whole.items() if index == main._INDEX_UNKNOWN} - assert moved, "expected at least one secondary key to be re-attributed" - for mac in moved: - assert whole[mac] < 19131 <= upper[mac] + assert unknown, "expected at least one secondary key in this range" + + # Every index that is reported at all agrees between the two calls, which is what makes it + # safe to keep. The ones that would have disagreed are exactly the ones reported as unknown. + for mac in set(whole) & set(upper): + if whole[mac] != main._INDEX_UNKNOWN and upper[mac] != main._INDEX_UNKNOWN: + assert whole[mac] == upper[mac] + + +def test_a_primary_key_index_survives_the_range_being_split(): + """The property the stored hint rests on: a primary key sits at one index and stays there.""" + accessory = json.dumps(_freshly_aligned_accessory()) + + whole = main.addressesBetween(accessory, 19100, 19160) + lower = main.addressesBetween(accessory, 19100, 19130) + + known = {mac: index for mac, index in lower.items() if index != main._INDEX_UNKNOWN} + + assert known + for mac, index in known.items(): + assert whole[mac] == index def test_addresses_between_refuses_nothing_for_an_empty_range(): From 06fc60ff04fc9f869e9a66ddb1dcffd4d9e25d53 Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:12:12 +0200 Subject: [PATCH 49/61] Measure how far the extrapolation runs from where a tag actually is Everything about which addresses are worth scanning for rests on extrapolating the stored alignment forward at one index every fifteen minutes, and on that extrapolation staying near the tag. The candidate margin, the bounded slice, how far back a search should reach: all of it is sized by argument. Nobody has measured whether the assumption holds, and the argument has now been used to justify a search that widens downward, which is either necessary or pure waste depending on a number that does not exist yet. An alignment that moves during a fetch is a real observation - something decrypted, so the new pair says where the tag was at a moment. Extrapolating the old pair forward to that same moment and subtracting gives the drift, for free, on every fetch that finds anything. Positive means the extrapolation ran ahead of the tag, which is the direction that loses it. Compared before and after rather than from a report's own index, which was the first attempt and only ever fired on the ranged fetch: the ordinary path calls fetch_location_history(accessory), and FindMy.py updates the alignment inside itself, so no index is ever visible here. The before-and-after pair is visible on both paths. An unchanged alignment reports nothing rather than a drift of zero. A fetch that found nothing to align to did not confirm the extrapolation, and a series full of zeroes that meant "nothing checked" would answer the question wrongly and confidently. Written to a file in the app's external files directory, not only to logcat, because the reading is worth something as a series over weeks and logcat on a busy phone holds minutes. Otherwise the measurement would mean leaving a phone plugged into a computer for a fortnight. Nothing private is in it: an index and a difference of two indices. Pulled with adb whenever the phone happens to be connected, capped at 128 KB, halved oldest-first past that. Python is only told where to write once per process, and only from somewhere that is about to start an interpreter anyway - starting one was measured at eleven to twelve seconds, which is not a price a diagnostic may charge on its own account. --- .../android/opentagviewer/MapsActivity.java | 5 + .../python/PythonDiagnostics.java | 68 +++++++++++ app/src/main/python/main.py | 111 ++++++++++++++++++ app/src/test/python/test_main.py | 57 +++++++++ 4 files changed, 241 insertions(+) create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/python/PythonDiagnostics.java 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 74b5b9e0..1ecb808d 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -106,6 +106,7 @@ import dev.wander.android.opentagviewer.python.AccessoryRequest; import dev.wander.android.opentagviewer.python.icloud.ICloudFailures; import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.PythonDiagnostics; import dev.wander.android.opentagviewer.python.LogRedactor; import dev.wander.android.opentagviewer.ui.BeaconIcon; import dev.wander.android.opentagviewer.python.PythonAppleService; @@ -499,6 +500,10 @@ protected void onCreate(Bundle savedInstanceState) { this.beaconRepo = new BeaconRepository( OpenTagViewerDatabase.getInstance(getApplicationContext())); + // The fetch this screen is about to run is what produces the drift measurement, so + // this is a place that starts Python anyway - see PythonDiagnostics. + PythonDiagnostics.attach(this); + this.sightingPersister = new AccessorySightingPersister(this.beaconRepo, new CachedPhoneLocation(new FusedPhoneLocation(this.getApplicationContext())), this::onHeardHere); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonDiagnostics.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonDiagnostics.java new file mode 100644 index 00000000..d6c15490 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonDiagnostics.java @@ -0,0 +1,68 @@ +package dev.wander.android.opentagviewer.python; + +import android.content.Context; +import android.util.Log; + +import com.chaquo.python.Python; + +import java.io.File; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * Gives the Python side somewhere to write a measurement that outlives a logcat buffer. + * + *

Why a file at all. The alignment drift the fetch path reports is only worth + * something as a series over weeks: one reading says nothing, and the question it answers - does + * a tag that is merely out of contact stay where the extrapolation says it is - is what decides + * how wide a search has to be. Logcat on a busy phone holds minutes, so relying on it would mean + * asking somebody to leave a phone plugged into a computer for a fortnight. + * + *

The app's external files directory, because that one can be pulled off a device with adb + * without root and without a debuggable build - which a release build is not. Nothing private + * goes in it: an index and a difference of two indices. + * + *

Attached lazily, and never on its own account. Reaching Python starts an interpreter, + * which was measured at eleven to twelve seconds on a device. Doing that so a diagnostic can + * introduce itself would be a bad trade, so this only ever runs from somewhere that is about to + * start Python anyway, on a background thread, once per process. + */ +public final class PythonDiagnostics { + private static final String TAG = PythonDiagnostics.class.getSimpleName(); + private static final String MODULE_MAIN = "main"; + + private static final AtomicBoolean attached = new AtomicBoolean(false); + + private PythonDiagnostics() {} + + /** + * Tells Python where to append diagnostics, the first time it is called in this process. + * + *

Silent on failure. Losing a diagnostic is not worth telling anybody about, and this + * must never be the reason something else did not happen. + */ + public static void attach(final Context context) { + if (!attached.compareAndSet(false, true)) { + return; + } + + final File directory = context.getApplicationContext().getExternalFilesDir(null); + if (directory == null) { + // No external storage mounted. Python keeps printing to logcat, which is what it did + // before there was a file at all. + Log.d(TAG, "No external files directory; diagnostics stay in logcat"); + return; + } + + Schedulers.io().scheduleDirect(() -> { + try { + Python.getInstance().getModule(MODULE_MAIN) + .callAttr("setDiagnosticsPath", directory.getAbsolutePath()); + Log.i(TAG, "Diagnostics will be appended to " + directory + "/diagnostics.log"); + } catch (final Exception couldNotAttach) { + Log.d(TAG, "Could not point Python at a diagnostics file", couldNotAttach); + } + }); + } +} diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index b3b3ec1a..cc108f1b 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -1,6 +1,7 @@ from enum import Enum from typing import Any, NamedTuple, cast import json +import os import time import traceback from datetime import datetime, timedelta, timezone @@ -1633,6 +1634,109 @@ def _updateAlignment(accessory: StoredAccessory, report, index): print(f"Could not update alignment: {traceback.format_exc()}") +def _alignmentOf(accessory: StoredAccessory): + """The stored (index, date) pair, or (None, None) for an accessory that has no alignment.""" + try: + mapping = accessory.to_json() + return mapping.get("alignment_index"), mapping.get("alignment_date") + except Exception: + return None, None + + +def _reportDrift(before_index, before_date, after_index, after_date) -> None: + """Says how far the extrapolation had run from where the tag turned out to be. + + **The one measurement that settles how wide a search has to be.** Everything about which + addresses are worth scanning for rests on extrapolating the stored alignment forward at one + index every fifteen minutes, and on that extrapolation staying close to where the tag really + is. Nobody has ever measured whether it does. The candidate window, the bounded slice, how + far back a search should reach - all of it is currently sized by argument rather than by a + number. + + An alignment that moved during a fetch is a real observation: something decrypted, so the new + pair says where the tag actually was at a moment. Extrapolating the *old* pair forward to that + same moment and subtracting gives the drift, as a signed number of indices, for free, on every + fetch that finds anything. + + Positive means the extrapolation had run ahead of the tag, which is the direction that loses + it: the search then looks above where the tag is. Around zero over weeks would mean a tag that + is merely out of contact stays where the extrapolation says, and a search that widens downward + is solving a problem nobody has. + + Compared this way rather than from a report's own index because the ordinary fetch never hands + one over: `fetch_location_history(accessory)` updates the alignment inside FindMy.py, so the + only place a report's index is visible in this file is the ranged path, which is the rarer + half. The before-and-after pair is visible in both. + """ + if None in (before_index, before_date, after_index, after_date): + return + + if before_index == after_index and before_date == after_date: + # The fetch found nothing to align to. Not a drift of zero, which is why it is not + # reported as one: a series full of those would read as a stable extrapolation. + return + + try: + moved_by = datetime.fromisoformat(after_date) - datetime.fromisoformat(before_date) + extrapolated = before_index + int(moved_by // timedelta(minutes=15)) + except Exception: + return + + index = after_index + + line = (f"Alignment drift: report at index {index}, extrapolated {extrapolated}, " + f"drift {extrapolated - index} index/indices " + f"({(extrapolated - index) / 4:.1f} hours ahead)") + + print(line) + _appendDiagnostic(line) + + +#: Where diagnostics are appended, set once by Java. None means logcat only. +_DIAGNOSTICS_PATH = None + +#: Past this the file is halved, oldest first. A drift line is about 110 bytes, so this keeps +#: something like the last thousand readings - months of fetches, and still nothing to notice. +_DIAGNOSTICS_MAX_BYTES = 128 * 1024 + + +def setDiagnosticsPath(path: str) -> None: + """Point diagnostics at a file, so a measurement outlives the logcat ring buffer. + + **Because the alternative was asking somebody to leave a phone plugged in.** The drift + measurement is only worth anything as a series over weeks, and logcat on a busy device + holds minutes. Java passes a directory it can reach without root - its own external files + directory - so the file can be pulled whenever the phone next happens to be connected. + """ + global _DIAGNOSTICS_PATH + _DIAGNOSTICS_PATH = os.path.join(path, "diagnostics.log") if path else None + + +def _appendDiagnostic(line: str) -> None: + """Adds one timestamped line, halving the file if it has grown past the cap. + + Never raises. A diagnostic that can break the thing it is measuring is worse than no + diagnostic, and this sits directly in the fetch path. + """ + path = _DIAGNOSTICS_PATH + if not path: + return + + try: + stamped = f"{datetime.now(timezone.utc).isoformat(timespec='seconds')} {line}\n" + + if os.path.exists(path) and os.path.getsize(path) > _DIAGNOSTICS_MAX_BYTES: + with open(path, "r", encoding="utf-8", errors="replace") as existing: + kept = existing.readlines() + with open(path, "w", encoding="utf-8") as trimmed: + trimmed.writelines(kept[len(kept) // 2:]) + + with open(path, "a", encoding="utf-8") as out: + out.write(stamped) + except Exception: + print(f"Could not write a diagnostic line: {traceback.format_exc()}") + + def _serializeReports(reports): """ Map FindMy 0.9.x LocationReport objects to the dict shape Java's mapResults expects. @@ -1719,6 +1823,7 @@ def getLastReports( # Measured before and after, because "found nothing" on its own says nothing. # See _DEAD_TAG_WIDTH_INDICES. width_before = _isAlignmentWide(airtag, start_dt, now_dt) + aligned_before = _alignmentOf(airtag) # Per-accessory isolation. One beacon failing used to abort the whole call, # which meant no beacon's updated alignment was persisted - so every later @@ -1734,6 +1839,12 @@ def getLastReports( print(f"Got {len(reports)} raw reports for {beaconId}") + # Measured here because this is the one place both halves are in scope: what the + # alignment said before anything was fetched, and what the fetch made of it. + aligned_after = _alignmentOf(airtag) + _reportDrift(aligned_before[0], aligned_before[1], + aligned_after[0], aligned_after[1]) + # A search that stayed as wide as it started found nothing to align to, which is # the difference between "no reports in the window asked for" and "no reports at # all, anywhere in this tag's life". diff --git a/app/src/test/python/test_main.py b/app/src/test/python/test_main.py index 07c78980..d7550d56 100644 --- a/app/src/test/python/test_main.py +++ b/app/src/test/python/test_main.py @@ -1690,3 +1690,60 @@ def test_addresses_between_returns_none_for_an_unreadable_accessory(): def test_candidate_window_returns_none_for_an_unreadable_accessory(): assert main.candidateWindow("not json at all") is None +def _drift_line(capsys, before_index, before_date, after_index, after_date): + main._reportDrift(before_index, before_date, after_index, after_date) + printed = capsys.readouterr().out + return [line for line in printed.splitlines() if "Alignment drift" in line] + + +def test_drift_is_not_reported_when_the_alignment_did_not_move(capsys): + """A fetch that found nothing to align to is not a drift of zero. + + Reporting it as one would fill the series with readings that say the extrapolation was + confirmed, when in fact nothing checked it. + """ + when = datetime.now(timezone.utc).isoformat() + + assert _drift_line(capsys, 19200, when, 19200, when) == [] + + +def test_drift_is_the_gap_between_extrapolation_and_where_the_tag_was(capsys): + """Six hours on, a tag that rolled on schedule is at 24 indices; one at 20 has drifted 4.""" + before = datetime(2026, 1, 1, tzinfo=timezone.utc) + after = before + timedelta(hours=6) + + lines = _drift_line(capsys, 19200, before.isoformat(), 19220, after.isoformat()) + + assert len(lines) == 1 + assert "drift 4 index/indices" in lines[0] + + +def test_a_tag_exactly_on_schedule_reports_no_drift(capsys): + before = datetime(2026, 1, 1, tzinfo=timezone.utc) + after = before + timedelta(hours=6) + + lines = _drift_line(capsys, 19200, before.isoformat(), 19224, after.isoformat()) + + assert len(lines) == 1 + assert "drift 0 index/indices" in lines[0] + + +def test_drift_is_signed_so_an_extrapolation_behind_the_tag_is_visible(capsys): + """Negative cannot happen if the extrapolation is a true upper bound, so it is worth + seeing rather than clamping away: one would mean that assumption is wrong.""" + before = datetime(2026, 1, 1, tzinfo=timezone.utc) + after = before + timedelta(hours=6) + + lines = _drift_line(capsys, 19200, before.isoformat(), 19230, after.isoformat()) + + assert len(lines) == 1 + assert "drift -6 index/indices" in lines[0] + + +def test_drift_is_silent_for_an_accessory_with_no_alignment_at_all(capsys): + assert _drift_line(capsys, None, None, 19200, "2026-01-01T00:00:00+00:00") == [] + + +def test_drift_survives_an_unparseable_date(capsys): + assert _drift_line(capsys, 19200, "not a date", 19220, "also not a date") == [] + From 78fe5fc376902a45820f91213ecb0e2f8fa6cf5c Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 29 Aug 2026 10:44:10 +0200 Subject: [PATCH 50/61] Put the ring button on the same pitch as the other three It was missing the layout_marginLeft and layout_marginRight of 8dp that Location History, Refresh and More all carry. The weights make the four containers equal in width and say nothing about where they sit, so the missing pair pushed the first gap 8dp wider than the other two - which on a phone reads as the Location History icon being off on its own. Reported by @parawanderer from a screenshot of a real card. The test measures between icon centres, because that is what an eye lines up. Its tolerance is 2dp: four weighted columns rarely divide a card width exactly, so neighbouring gaps land a pixel or two apart, and a tighter bound fails on rounding while proving nothing. Measured with the margins removed again: 254px against 234px, which is the 8dp. Co-Authored-By: Claude Opus 5 --- .../ui/maps/TagCardLayoutTest.java | 68 +++++++++++++++++++ app/src/main/res/layout/maps_tag_card.xml | 2 + 2 files changed, 70 insertions(+) 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 36fb4402..f960ef19 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 @@ -8,6 +8,7 @@ import android.content.Context; import android.content.res.Configuration; +import android.graphics.Rect; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -519,6 +520,73 @@ private List measureIconVariantHeights(final float fontScale) { *

Ring is included alongside the other three - see * {@code dev.wander.android.opentagviewer.ble} for what is behind it now. */ + /** + * The four actions are evenly spaced across the row. + * + *

They are laid out with {@code layout_weight="1"} apiece, which makes them equal in + * width and says nothing about where they sit: the margins between them are what puts + * them on an even pitch, and one container missing a pair of them shifts every gap around it + * without changing any width. Ring shipped without its 8dp margins, which pushed the first + * gap 8dp wider than the other two - visible on a phone as Location History sitting too far + * from Refresh, and invisible to + * {@link #everyActionOnTheCardStillHasRoomWithTheWorstContent}, which asks about sizes. + * + *

Measured between the centres of the icons rather than the containers, because the icon + * is the thing a person's eye lines up. + * + *

The tolerance is in dp, and it is the difference between a test and a nuisance. + * Four weighted columns rarely divide a card width exactly, so neighbouring gaps land a + * pixel or two apart - measured at 243 and 245 here - and a 1px tolerance fails on that + * while proving nothing. A missing margin is 8dp, which is four times this threshold at any + * density, so the gap between "rounding" and "the bug" is wide and this sits in it. + */ + @Test + public void theFourActionsAreEvenlySpacedAcrossTheRow() { + final int[] centres = new int[4]; + final int[] iconIds = { + R.id.history_icon, + R.id.refresh_icon, + R.id.perform_ring_icon, + R.id.tag_more_icon, + }; + + getInstrumentation().runOnMainSync(() -> { + final FrameLayout card = (FrameLayout) LayoutInflater.from(this.context) + .inflate(R.layout.maps_tag_card, null); + + final int width = cardWidthFor(SCREEN_WIDTH_PX); + card.measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + card.layout(0, 0, width, card.getMeasuredHeight()); + + for (int i = 0; i < iconIds.length; i++) { + final View icon = card.findViewById(iconIds[i]); + + // **Offsets within the card, not window coordinates.** This card is inflated + // with no parent and never attached, so getLocationInWindow has no window to + // answer about and reports positions that are all but identical - which came + // out as negative gaps rather than as an obvious "this measured nothing". + final Rect bounds = new Rect(0, 0, icon.getWidth(), icon.getHeight()); + card.offsetDescendantRectToMyCoords(icon, bounds); + centres[i] = bounds.centerX(); + } + }); + + final float density = this.context.getResources().getDisplayMetrics().density; + final int roundingSlackPx = Math.round(2 * density); + + final int firstGap = centres[1] - centres[0]; + for (int i = 1; i < centres.length - 1; i++) { + final int gap = centres[i + 1] - centres[i]; + assertTrue("the gap between action " + i + " and " + (i + 1) + " is " + gap + + "px, but the first gap is " + firstGap + "px - the row is not on an" + + " even pitch, which usually means one container is missing the 8dp" + + " margins the others have", + Math.abs(gap - firstGap) <= roundingSlackPx); + } + } + @Test public void everyActionOnTheCardStillHasRoomWithTheWorstContent() { final int[][] sizes = new int[4][2]; diff --git a/app/src/main/res/layout/maps_tag_card.xml b/app/src/main/res/layout/maps_tag_card.xml index 07ecf4cf..7697a9c1 100644 --- a/app/src/main/res/layout/maps_tag_card.xml +++ b/app/src/main/res/layout/maps_tag_card.xml @@ -293,6 +293,8 @@ android:id="@+id/device_ring_button_container" android:layout_width="0dp" android:layout_height="wrap_content" + android:layout_marginLeft="8dp" + android:layout_marginRight="8dp" android:layout_weight="1" android:background="@drawable/ripple_rounded_rect" android:clickable="true" From 60c1aee40fdc64e3bcfc1d0910d07d26f5199d9a Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 29 Aug 2026 10:44:10 +0200 Subject: [PATCH 51/61] Only warn about a long fetch when the fetch is going to be long The "still locating your tags (2 of 3)" banner went up for any fetch still running after six seconds, which on a slow network is most of them - so it appeared during loads that finish immediately. A warning that shows when nothing is wrong is one people stop reading before the day it matters. What makes a fetch long is the key search, and how far back that starts is decided by the accessory's KeyAlignmentRecord - so it is knowable before a request goes out. Keys advance every fifteen minutes and Apple takes about 290 per request, which puts a week at roughly three requests and an unaligned eighteen-month-old tag at about 180. The threshold is a week; the full arithmetic is on SlowFirstFetch. Asked of the requests actually built rather than of the tags on screen, because the scheduled fetch drops tags that are ignored or backing off - an unaligned tag nobody is fetching should not warn about a wait that is not happening. Tested on both sides of the bridge between decision and data: the arithmetic on the JVM, and the reading of a real alignment plist off a stored beacon on a device. A wrong XPath would otherwise answer "unaligned, so slow" for every tag and restore the old behaviour with the JVM tests still green. Reported by @parawanderer, who noted the loads finish quickly for him. Co-Authored-By: Claude Opus 5 --- .../repo/WhichFetchesAreWorthABannerTest.java | 167 ++++++++++++++++++ .../db/repo/BeaconRepository.java | 29 +++ .../opentagviewer/util/rx/SlowFirstFetch.java | 79 +++++++++ .../util/rx/SlowFirstFetchTest.java | 103 +++++++++++ 4 files changed, 378 insertions(+) create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/WhichFetchesAreWorthABannerTest.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetch.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetchTest.java diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/WhichFetchesAreWorthABannerTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/WhichFetchesAreWorthABannerTest.java new file mode 100644 index 00000000..262a4c83 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/WhichFetchesAreWorthABannerTest.java @@ -0,0 +1,167 @@ +package dev.wander.android.opentagviewer.db.repo; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +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.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.AccessoryRequest; + +/** + * Which fetches put the "still locating your tags" banner up. + * + *

The bug, from using the app: the banner appeared during loads that finished + * immediately. It went up for any fetch still running after six seconds, which on a slow + * network is most of them - and a warning that shows when nothing is wrong is one people stop + * reading before the day it matters. + * + *

What makes a fetch genuinely long is the key search, and how far back that starts is + * decided by the accessory's {@code KeyAlignmentRecord} - so it is knowable before a single + * request goes out. {@code SlowFirstFetchTest} covers the arithmetic on the JVM; this covers + * the part that needs a database, which is reading the record off the stored beacon at all. + * + *

Both halves are needed. The predicate is right and useless if the plist never parses, and + * a wrong XPath here would silently answer "no alignment, so slow" for every tag - restoring + * exactly the behaviour being fixed, with the JVM tests still green. + */ +@RunWith(AndroidJUnit4.class) +public class WhichFetchesAreWorthABannerTest { + + private static final String A_PLIST = ""; + + 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\"}"); + } + + @After + public void closeIt() { + this.db.close(); + } + + /** The shape the exporter writes: a key, then its typed sibling. */ + private static String alignedAt(final Instant when) { + return "" + + "lastIndexObservationDate" + + "" + when.toString() + "" + + ""; + } + + private void givenABeacon(final String id, final String alignmentPlist) { + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(id) + .content(A_PLIST) + .alignmentPlist(alignmentPlist) + .version("0.0.2") + .fromAccount(false) + .isRemoved(false) + .build()); + } + + private boolean wouldBeSlow(final String... beaconIds) { + final List requests = new java.util.ArrayList<>(); + for (final String id : beaconIds) { + requests.add(new AccessoryRequest(id, "{\"type\":\"accessory\"}")); + } + return this.repo.aFetchOfTheseWouldBeSlow(requests).blockingFirst(); + } + + // ------------------------------------------------------------------ quick, so stay quiet + + @Test + public void aTagAlignedThisMorningNeedsNoBanner() { + this.givenABeacon("recent", alignedAt(Instant.now().minus(6, ChronoUnit.HOURS))); + + assertFalse("a few hours of keys is one request", this.wouldBeSlow("recent")); + } + + /** + * The regression this is really for. If the plist stops parsing - a changed XPath, a + * date format nobody anticipated - every tag reads as unaligned and the banner comes back + * for everything, which is the behaviour being fixed. Only a real record through the real + * reader catches that. + */ + @Test + public void aStoredAlignmentRecordIsActuallyRead() { + this.givenABeacon("aligned", alignedAt(Instant.now().minus(2, ChronoUnit.DAYS))); + + assertFalse("the alignment record was stored but not read, so this tag looked unaligned", + this.wouldBeSlow("aligned")); + } + + @Test + public void aWholeBatchOfRecentlyAlignedTagsNeedsNoBanner() { + this.givenABeacon("a", alignedAt(Instant.now().minus(1, ChronoUnit.DAYS))); + this.givenABeacon("b", alignedAt(Instant.now().minus(2, ChronoUnit.DAYS))); + this.givenABeacon("c", alignedAt(Instant.now().minus(3, ChronoUnit.DAYS))); + + assertFalse("three quick tags is still a quick fetch", this.wouldBeSlow("a", "b", "c")); + } + + // ------------------------------------------------------------------ slow, so say so + + @Test + public void aTagWithNoAlignmentRecordIsWorthABanner() { + this.givenABeacon("never-aligned", null); + + assertTrue("with no record it searches from the pairing date", + this.wouldBeSlow("never-aligned")); + } + + @Test + public void aTagAlignedMonthsAgoIsWorthABanner() { + this.givenABeacon("stale", alignedAt(Instant.now().minus(90, ChronoUnit.DAYS))); + + assertTrue("three months is roughly 8,600 keys", this.wouldBeSlow("stale")); + } + + @Test + public void oneUnalignedTagAmongQuickOnesStillWarrantsIt() { + this.givenABeacon("quick", alignedAt(Instant.now().minus(1, ChronoUnit.DAYS))); + this.givenABeacon("unaligned", null); + + assertTrue("the batch is fetched one at a time, so the slow one holds up the rest", + this.wouldBeSlow("quick", "unaligned")); + } + + /** + * A tag being fetched that this app has no row for - a self-generated one, or a request + * built from a fallback plist. Unknown, so warn rather than stay silent. + */ + @Test + public void aTagWithNoStoredRowAtAllWarnsRatherThanStaysSilent() { + assertTrue("nothing is known about it, and silence is the failure being avoided", + this.wouldBeSlow("never-heard-of-it")); + } + + /** Damaged rather than absent: unreadable is the same answer as unknown, not a crash. */ + @Test + public void anUnreadableAlignmentRecordIsTreatedAsUnaligned() { + this.givenABeacon("damaged", "lastIndex" + + "ObservationDatenot a date at all"); + + assertTrue("an unreadable record tells us nothing about where the search starts", + this.wouldBeSlow("damaged")); + } +} 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 b47a3a97..4480e0e5 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 @@ -38,6 +38,7 @@ 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.SlowFirstFetch; import dev.wander.android.opentagviewer.util.rx.WideScanBackoff; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Observable; @@ -488,6 +489,34 @@ public Observable> neverScanned() { .subscribeOn(Schedulers.io()); } + /** + * Whether fetching these accessories means a long key search. + * + *

Asked of the requests that were actually built rather than of everything on screen: the + * scheduled fetch skips tags that are ignored or backing off, and an unaligned tag that is + * not being fetched should not put a banner up about a wait that is not happening. + * + *

The same {@code observedAtMillis} XPath the scan ordering uses, over a plist already in + * memory - see {@link #dueForAScheduledScan}, which explains why this is read rather than + * stored in a column. + * + * @see dev.wander.android.opentagviewer.util.rx.SlowFirstFetch for the arithmetic. + */ + public Observable aFetchOfTheseWouldBeSlow(final List requests) { + return Observable.fromCallable(() -> { + final var dao = db.ownedBeaconDao(); + final List alignedAt = new ArrayList<>(); + + for (final AccessoryRequest request : requests) { + final OwnedBeacon row = dao.getById(request.getBeaconId()); + alignedAt.add(row == null + ? null : KeyAlignmentPlist.observedAtMillis(row.alignmentPlist)); + } + + return SlowFirstFetch.isLikely(alignedAt, System.currentTimeMillis()); + }).subscribeOn(Schedulers.io()); + } + public Observable> toAccessoryRequests(Map beaconIdToPlistFallback) { return Observable.fromCallable(() -> { if (beaconIdToPlistFallback.isEmpty()) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetch.java b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetch.java new file mode 100644 index 00000000..997f942e --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetch.java @@ -0,0 +1,79 @@ +package dev.wander.android.opentagviewer.util.rx; + +import java.util.Collection; +import java.util.concurrent.TimeUnit; + +/** + * Whether a batch of tags is one that will take minutes rather than seconds. + * + *

The banner was showing for fetches that finish immediately. "Still locating your + * tags (2 of 3)" is worth saying when the wait is genuinely long, and is noise otherwise - and a + * message that appears when nothing is wrong is one people learn to ignore before the day it + * matters. It went up for every fetch that passed six seconds, which on a slow network is most + * of them. + * + *

What actually makes a fetch long is the key search, and that is knowable in advance. + * An accessory is located by deriving the rotating keys it would have published and asking + * Apple's network about them. Where the search starts is set by the {@code KeyAlignmentRecord} + * in the export: with a recent one the app resumes near where the tag is now, and with none at + * all {@code FindMyAccessory} starts at index 0 from the pairing date and searches the tag's + * whole life - see AGENTS.md rule 6, which is about the same records and why both paths have to + * keep working. + * + *

Keys advance every fifteen minutes, so 96 a day, and Apple takes roughly 290 of them per + * request. The arithmetic that follows is the whole of this class: + * + * + * + * + * + * + * + *
Alignment last observedKeys to searchRequests
yesterday~961
a week ago~6723
a month ago~2,88010
never (18-month-old tag)~52,000~180
+ * + *

The threshold sits at a week, where the search is still a couple of requests and finishes + * while somebody is looking at the screen. It errs towards showing the banner: being told to + * wait for something that turns out to be quick costs a moment's attention, and being told + * nothing during three minutes of apparent hang is what this whole mechanism exists to prevent. + * + *

Pure and on the JVM, per AGENTS.md rule 13 - it takes timestamps and returns a boolean. + */ +public final class SlowFirstFetch { + + /** + * How stale an alignment record has to be before its fetch is worth warning about. + * + *

Seven days is about three requests. Below that the search is over before the banner's + * six-second delay has elapsed, so showing it would only ever be a flash. + */ + static final long STALE_AFTER_MS = TimeUnit.DAYS.toMillis(7); + + private SlowFirstFetch() { + } + + /** + * @param alignmentObservedAt when each tag in the batch last had its keys aligned. A + * {@code null} entry is a tag with no alignment record at all, + * which is the slowest case there is. + * @param now the current time, passed in so this can be tested without a + * clock. + * @return true if any one of them will search far enough back to be worth a banner. One is + * enough: the batch is fetched one accessory at a time, so a single unaligned tag + * holds up everything behind it. + */ + public static boolean isLikely(final Collection alignmentObservedAt, final long now) { + if (alignmentObservedAt == null) { + // Nothing known about the batch. Treated as slow, because the alternative is + // silence during the exact case the banner is for. + return true; + } + + for (final Long observedAt : alignmentObservedAt) { + if (observedAt == null || now - observedAt > STALE_AFTER_MS) { + return true; + } + } + + return false; + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetchTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetchTest.java new file mode 100644 index 00000000..c0d15cb8 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetchTest.java @@ -0,0 +1,103 @@ +package dev.wander.android.opentagviewer.util.rx; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Which batches are worth warning somebody about, and which are not. + * + *

The banner used to go up for any fetch that took longer than six seconds, which on a slow + * network is nearly all of them. What makes a fetch genuinely long is how far back the key + * search has to start, and that is known before the request is sent - so these are the cases + * that decide it. + */ +public class SlowFirstFetchTest { + + private static final long NOW = 1_750_000_000_000L; + + private static long daysAgo(final int days) { + return NOW - TimeUnit.DAYS.toMillis(days); + } + + // ------------------------------------------------------------------ quick, so stay quiet + + @Test + public void aTagAlignedYesterdayIsOneRequestAndNeedsNoBanner() { + assertFalse("a day of keys is about 96, well inside one request", + SlowFirstFetch.isLikely(Collections.singletonList(daysAgo(1)), NOW)); + } + + @Test + public void aWholeBatchOfRecentlyAlignedTagsNeedsNoBanner() { + assertFalse("every one of them resumes near where it is now", + SlowFirstFetch.isLikely( + Arrays.asList(daysAgo(1), daysAgo(3), daysAgo(6)), NOW)); + } + + @Test + public void nothingToFetchIsNotSlow() { + assertFalse("an empty batch cannot take minutes", + SlowFirstFetch.isLikely(Collections.emptyList(), NOW)); + } + + /** The boundary itself, stated explicitly so a change to it has to be deliberate. */ + @Test + public void exactlyAtTheThresholdIsStillQuick() { + assertFalse("seven days is about three requests, which finishes while you watch", + SlowFirstFetch.isLikely( + Collections.singletonList(NOW - SlowFirstFetch.STALE_AFTER_MS), NOW)); + } + + // ------------------------------------------------------------------ slow, so say so + + @Test + public void aTagWithNoAlignmentRecordAtAllIsTheSlowestCase() { + assertTrue("with no record it starts at index 0 from the pairing date", + SlowFirstFetch.isLikely(Collections.singletonList(null), NOW)); + } + + @Test + public void aTagAlignedAMonthAgoIsWorthWarningAbout() { + assertTrue("about 2,880 keys, so roughly ten sequential requests", + SlowFirstFetch.isLikely(Collections.singletonList(daysAgo(30)), NOW)); + } + + /** + * One slow tag is enough, and this is the case the whole change turns on. + * + *

The batch is fetched one accessory at a time, so a single unaligned tag holds up every + * tag behind it. A rule of "most of them are quick" would stay silent through exactly the + * three-minute wait the banner exists for. + */ + @Test + public void oneUnalignedTagAmongManyQuickOnesIsStillSlow() { + final List batch = Arrays.asList(daysAgo(1), null, daysAgo(2)); + + assertTrue("the unaligned one holds up the two behind it", + SlowFirstFetch.isLikely(batch, NOW)); + } + + @Test + public void knowingNothingAboutTheBatchWarnsRatherThanStaysSilent() { + assertTrue("silence during a three-minute hang is the failure being avoided", + SlowFirstFetch.isLikely(null, NOW)); + } + + /** + * A clock that has gone backwards - a device whose time was wrong and got corrected - must + * not read as "aligned in the future, so very fresh" and suppress the banner forever. + */ + @Test + public void anAlignmentInTheFutureIsTreatedAsFresh() { + assertFalse("a future timestamp is nonsense, but it is not evidence of a long search", + SlowFirstFetch.isLikely( + Collections.singletonList(NOW + TimeUnit.DAYS.toMillis(2)), NOW)); + } +} From c4d5ba73da0d9c687ee8a65d4bff617083333232 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 29 Aug 2026 10:44:27 +0200 Subject: [PATCH 52/61] Stop the iCloud offer coming back, and say so when the connection is broken Two faults behind one report: the prompt returned days later, on a device whose account was already connected. **The offer was recorded on an object that gets replaced.** offerIfDue marks the settings it is handed, and that was this.userSettings - the field onResume re-reads from storage on every single resume. So the dialog marked one object, the activity resumed and the field became a different one still saying the offer had never been made, and the answer then saved that. Nothing failed and nothing logged. The settings are now read where the offer is made, and the flag is written when the dialog is shown rather than when it is answered - which is what the class documented all along, and what makes a dialog dismissed by teardown count as the one time. **And an unreadable connection looked identical to no connection.** The membership read returned an empty Optional both for somebody who had never joined and for somebody whose stored keys no longer decrypt, so a device whose secure storage had moved on got the first-time setup offer - once - and if that was declined the app behaved from then on as though iCloud had never been wanted, with account reads silently failing. There is no repair available: those keys are the only copy of the means to use a peer that exists on the account, so the remedy is to join again. But it is now said out loud, with the reassurance that matters - the tags and their history are untouched, and nothing was removed from the Apple account. The row is deliberately not deleted on a decrypt failure, since a transient keystore problem must not destroy a membership that would read again later. Suspected rather than confirmed for @parawanderer's device: the race is demonstrable and fixed, and this second path is handled because it produces the same symptom and could not be ruled out from here. Each new test was run against the unfixed code first: the resume one and the "recorded when shown" one both go red without the change. Co-Authored-By: Claude Opus 5 --- .../TheICloudOfferAppearsOnceTest.java | 106 +++++++++++++ .../android/opentagviewer/MapsActivity.java | 149 ++++++++++++++++-- .../db/repo/KeychainMembershipRepository.java | 97 +++++++++--- app/src/main/res/values-de/strings.xml | 4 + app/src/main/res/values-en/strings.xml | 4 + app/src/main/res/values-fr/strings.xml | 4 + app/src/main/res/values-ja/strings.xml | 4 + app/src/main/res/values-ko/strings.xml | 4 + app/src/main/res/values-nl/strings.xml | 4 + app/src/main/res/values-ru/strings.xml | 4 + app/src/main/res/values-zh-rCN/strings.xml | 4 + app/src/main/res/values-zh-rTW/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + 13 files changed, 349 insertions(+), 43 deletions(-) diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/TheICloudOfferAppearsOnceTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/TheICloudOfferAppearsOnceTest.java index 1757369a..2fb6baaf 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/TheICloudOfferAppearsOnceTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/TheICloudOfferAppearsOnceTest.java @@ -16,6 +16,7 @@ import android.content.Context; import android.content.Intent; +import androidx.lifecycle.Lifecycle; import androidx.test.core.app.ActivityScenario; import androidx.test.espresso.NoMatchingRootException; import androidx.test.espresso.NoMatchingViewException; @@ -209,6 +210,111 @@ public void itneverComesBackOnceItHasBeenSeen() { this.theOfferIsShowing()); } + /** + * Showing it is what records it, before anybody has answered. + * + *

The class promises that a dialog dismissed by the activity being torn down still counts + * as the one time. That only holds if the write happens when the dialog goes up - so this + * asserts exactly that, with nothing pressed. + */ + @Test + public void theOfferIsRecordedTheMomentItIsShown() { + this.settingsWhere(settings -> settings.setAnisetteMode(UserSettings.ANISETTE_LOCAL)); + + this.openTheMap(); + Eventually.check(() -> onView(withText(R.string.icloud_offer_title)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + + Eventually.check(() -> assertTrue( + "the offer has to be recorded when it is shown, not when it is answered", + this.theOfferHasBeenMade())); + } + + /** + * A resume while the offer is up must not lose the record of it. + * + *

This is the bug a user hit: the prompt came back days after they had answered it, with + * an account already connected. {@code MapsActivity.onResume} re-reads the settings into the + * field the dialog had marked, so the marked object was replaced by a fresh one still saying + * the offer had never been made - and the answer then saved that. Nothing failed, nothing + * logged, and the prompt returned on every launch. + * + *

A resume between showing and answering is not a contrived sequence: it is what happens + * when somebody glances at another app and comes back, and it is also the ordinary + * onCreate/onResume ordering when the membership lookup answers quickly. + * + *

Confirmed to fail before the fix - the stored flag came back false, and the offer + * appeared again on reopening. + */ + @Test + public void theOfferSurvivesAResumeWhileItIsOnScreen() { + this.settingsWhere(settings -> settings.setAnisetteMode(UserSettings.ANISETTE_LOCAL)); + + this.openTheMap(); + Eventually.check(() -> onView(withText(R.string.icloud_offer_title)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + + // The step that used to swap the settings object out from under the dialog. + this.scenario.moveToState(Lifecycle.State.STARTED); + this.scenario.moveToState(Lifecycle.State.RESUMED); + + Eventually.check(() -> assertTrue( + "a resume while the dialog was up threw away the record that it was offered", + this.theOfferHasBeenMade())); + + this.scenario.close(); + this.openTheMap(); + + this.letTheMapSettle(); + assertFalse("the offer came back after a resume, which is the reported bug", + this.theOfferIsShowing()); + } + + /** + * A connection that exists but cannot be read is a fault, not a fresh install. + * + *

The membership read used to answer "empty" both for somebody who had never joined and + * for somebody whose stored keys could no longer be decrypted - so a device whose secure + * storage had moved on was shown the first-time setup offer, once, and if that was declined + * the app behaved from then on as though iCloud had never been wanted. Nothing said anything + * was wrong; account reads just stopped working. + * + *

Two claims here, and the second is the one with teeth: they are told what happened, and + * their one-and-only first-time offer is not spent on it. Spending it would leave + * somebody who dismissed a message they did not understand with no way back to the feature + * except a Settings item they have no reason to open. + */ + @Test + public void anUnreadableConnectionAsksThemToReconnectRatherThanOfferingSetup() { + this.settingsWhere(settings -> settings.setAnisetteMode(UserSettings.ANISETTE_LOCAL)); + this.givenAStoredMembershipThatCannotBeRead(); + + this.openTheMap(); + + Eventually.check(() -> onView(withText(R.string.icloud_membership_unreadable_title)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + + assertFalse("the one-time offer must not be spent on a broken connection", + this.theOfferHasBeenMade()); + } + + /** + * Bytes under the membership key that are not a ciphertext this app can open. + * + *

Written straight into the store rather than through {@code store()}, because the point + * is a value that decryption fails on - which is what a rotated or lost keystore key leaves + * behind, and there is no way to ask the real writer to produce one. + */ + private void givenAStoredMembershipThatCannotBeRead() { + UserAuthDataStore.getInstance(this.context).updateDataAsync(preferences -> { + final androidx.datastore.preferences.core.MutablePreferences mutable = + preferences.toMutablePreferences(); + mutable.set(dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore + .KEYCHAIN_MEMBERSHIP, new byte[] {1, 2, 3, 4, 5, 6, 7, 8}); + return io.reactivex.rxjava3.core.Single.just(mutable); + }).blockingGet(); + } + /** * Somebody already reading their account is not asked. * 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 1ecb808d..6e202fc7 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.bluetooth.le.ScanSettings; import android.content.Intent; import android.content.pm.ApplicationInfo; +import android.app.Dialog; import android.content.pm.PackageManager; import android.content.res.ColorStateList; import android.location.Address; @@ -2002,30 +2003,101 @@ private void offerICloudSetupIfDue() { var async = new KeychainMembershipRepository( UserAuthDataStore.getInstance(this.getApplicationContext()), new AppCryptographyUtil()) - .get() + .state() .firstOrError() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( - held -> ICloudSetupOfferDialog.offerIfDue( - this, this.userSettings, held.isPresent(), this::recordICloudOffer), + state -> this.respondToTheMembershipState(state), error -> Log.w(TAG, "Could not tell whether an account is linked, so not offering" + " to connect one", error)); } - /** Persist the answer, and act on it if they said yes. */ - private void recordICloudOffer(final boolean accepted) { - var async = this.userSettingsRepo.storeUserSettings(this.userSettings) + /** + * Three situations, and only one of them is the first-time offer. + * + *

An account that was connected and can no longer be read is not a new user. The + * membership read used to collapse "never joined" and "joined but undecryptable" into the + * same empty answer, so a device whose secure storage had moved on was offered the + * first-time setup - once, silently - and if that offer was declined the app then behaved + * as though iCloud had never been wanted. Nothing said anything was wrong; the account reads + * simply stopped working. + * + *

So the broken case gets its own screen, saying what happened and that the tags and + * their history are untouched. It is not gated on the one-time flag: this is a fault to fix + * rather than a preference to express, and it stops appearing the moment it is fixed. + */ + private void respondToTheMembershipState(final KeychainMembershipRepository.MembershipState state) { + switch (state) { + case HELD: + // Already reading the account. Nothing to offer and nothing wrong. + return; + case UNREADABLE: + Log.w(TAG, "The stored keychain membership cannot be read, so telling them to" + + " connect the account again rather than offering it as though new"); + this.askThemToReconnectTheAccount(); + return; + case NONE: + default: + this.offerICloudSetupTo(false); + } + } + + private void askThemToReconnectTheAccount() { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.icloud_membership_unreadable_title) + .setMessage(R.string.icloud_membership_unreadable_message) + .setPositiveButton(R.string.icloud_offer_set_up_now, + (dialog, which) -> this.actOnTheICloudOffer(true)) + .setNegativeButton(R.string.icloud_offer_not_now, (dialog, which) -> { }) + .show(); + } + + /** + * Ask, and write down that we asked - immediately, and on settings read here. + * + *

The prompt was coming back, and this is why. {@code offerIfDue} records the offer + * by setting a flag on the settings object it is handed, and that used to be + * {@code this.userSettings} - a field {@link #onResume} replaces with a fresh read on every + * single resume. So the sequence was: the dialog goes up and marks the object it was given; + * the activity resumes and the field becomes a different object, one still saying the offer + * was never made; the user answers; and the answer saves *that* object. The flag never + * reached storage, and the prompt returned on the next launch, and the next. + * + *

Two changes, and both are needed. The settings are read here rather than taken from the + * field, so nothing else can swap the object out underneath the dialog. And the write happens + * when the dialog is shown, not when it is answered - the gap between those was the + * race, and it also means a dialog dismissed by the activity being destroyed still counts, + * which is what {@code offerIfDue} promises. + */ + private void offerICloudSetupTo(final boolean hasLinkedAccount) { + final UserSettings settings = this.userSettingsRepo.getUserSettings(); + + final Dialog offered = ICloudSetupOfferDialog.offerIfDue( + this, settings, hasLinkedAccount, this::actOnTheICloudOffer); + + if (offered == null) { + return; + } + + // Keeps the field in step for anything else reading it before the next resume. It is the + // stored copy that decides this next launch, and that is written below. + this.userSettings = settings; + + var async = this.userSettingsRepo.storeUserSettings(settings) .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(() -> { - if (accepted) { - Log.i(TAG, "taking them to connect an iCloud account"); - this.fetchFromICloudLauncher.launch( - new Intent(this, FetchFromICloudActivity.class)); - } - }, error -> Log.e(TAG, "Failed to record the iCloud offer", error)); + .subscribe(() -> Log.i(TAG, "recorded that the iCloud offer was made"), + error -> Log.e(TAG, "Failed to record the iCloud offer, so it will be" + + " made again on the next launch", error)); + } + + /** Act on the answer. Recording that it was asked already happened, when it was shown. */ + private void actOnTheICloudOffer(final boolean accepted) { + if (accepted) { + Log.i(TAG, "taking them to connect an iCloud account"); + this.fetchFromICloudLauncher.launch(new Intent(this, FetchFromICloudActivity.class)); + } } private static boolean isAccountRestoreFailure(Throwable t) { @@ -2611,6 +2683,7 @@ private Observable>> fetchLastReports(fin // asking, and asks about whatever it was given. return this.beaconRepo.toScheduledAccessoryRequests(beaconIdToPlist) .doOnSubscribe(__ -> this.markFetchStarted()) + .doOnNext(this::armLongFetchBannerIfSlow) .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack)) .doOnNext(reports -> this.refreshPolicy.markFetched(now)) // on success, update this time. .doFinally(this::markFetchFinished); @@ -2620,6 +2693,7 @@ private Observable>> fetchLastReports(fin Log.d(TAG, "Preparing to fetch location reports for the last " + hoursToGoBack + " hours!"); return this.beaconRepo.toAccessoryRequests(beaconIdToPlist) .doOnSubscribe(__ -> this.markFetchStarted()) + .doOnNext(this::armLongFetchBannerIfSlow) .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack)) .doFinally(this::markFetchFinished); } @@ -2677,6 +2751,7 @@ private Observable>> fetchLastReportsFor( // Not Map.of - see BeaconRepository.plistFallback. A self-generated tag has no plist. return this.beaconRepo.toAccessoryRequests(BeaconRepository.plistFallback(beaconId, pList)) .doOnSubscribe(__ -> this.markFetchStarted()) + .doOnNext(this::armLongFetchBannerIfSlow) .flatMap(requests -> this.appleService.getLastReports(requests, hoursToGoBack)) .flatMap(this.beaconRepo::storeFetchResult) .doFinally(this::markFetchFinished); @@ -2695,11 +2770,51 @@ private Observable>> fetchLastReportsFor( * part-way through discards the work for all of them and the next launch starts over. */ private void markFetchStarted() { + this.longFetchBannerHandler.post(this.bannerState::fetchStarted); + } + + /** + * Arms the banner, but only for a batch that is actually going to be slow. + * + *

Counting a fetch and warning about one are now separate things. This used to arm + * on every fetch that passed six seconds, which on a slow network is most of them - so the + * message showed up during loads that finished immediately, and a warning that appears when + * nothing is wrong is one people stop reading. The wait it exists for is the key search + * described above, and whether that search is long is known before the request goes out: it + * depends on how far back the accessory's alignment record starts it. + * + *

Asked of the requests rather than of the tags on screen, because the scheduled fetch + * drops tags that are ignored or backing off - an unaligned tag nobody is fetching should + * not put up a banner about a wait that is not happening. + * + *

The lookup is a database read and the banner is six seconds away, so there is time; and + * it re-checks that a fetch is still in flight before arming, since a quick batch can finish + * while the question is being answered. + */ + private void armLongFetchBannerIfSlow(final List requests) { + var async = this.beaconRepo.aFetchOfTheseWouldBeSlow(requests) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + slow -> this.armLongFetchBanner(slow), + // On doubt, warn. Silence through a three-minute wait is the failure + // this mechanism exists to prevent; a banner during a quick fetch is + // merely untidy. + error -> { + Log.w(TAG, "Could not tell whether this fetch will be slow," + + " so assuming it might be", error); + this.armLongFetchBanner(true); + }); + } + + private void armLongFetchBanner(final boolean slow) { this.longFetchBannerHandler.post(() -> { - if (this.bannerState.fetchStarted()) { - this.longFetchBannerHandler.postDelayed( - this.showLongFetchBanner, SHOW_LONG_FETCH_BANNER_AFTER_MS); + if (!slow || !this.bannerState.isFetching()) { + return; } + this.longFetchBannerHandler.removeCallbacks(this.showLongFetchBanner); + this.longFetchBannerHandler.postDelayed( + this.showLongFetchBanner, SHOW_LONG_FETCH_BANNER_AFTER_MS); }); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/KeychainMembershipRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/KeychainMembershipRepository.java index c2a66edf..e3ea49a9 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/KeychainMembershipRepository.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/KeychainMembershipRepository.java @@ -55,37 +55,82 @@ public KeychainMembershipRepository( this.cryptography = cryptography; } - /** The membership, or empty when this app has not joined - which is the ordinary first run. */ - public Observable> get() { - return Observable.fromPublisher(this.store.data()).map(preferences -> { - final byte[] encrypted = preferences.get(KEYCHAIN_MEMBERSHIP); - if (encrypted == null) { - return Optional.empty(); - } + /** + * What this app holds, told apart from what it can use. + * + *

"Nothing stored" and "stored but unreadable" are different situations with the same + * shape. Collapsing them into an empty Optional is right for most callers - either way + * there is no membership to use - but it makes the app behave as though the account was + * never connected, which is wrong in a way the user can see: they are offered a first-time + * setup for something they already did, and nothing anywhere says why. + */ + public enum MembershipState { + /** Never joined. The ordinary first run. */ + NONE, + /** Joined, and the keys are usable. */ + HELD, + /** + * Joined, and the stored keys cannot be decrypted. + * + *

The keys are the only copy of the means to use a peer that exists on the user's + * account, so this is not recoverable here - the remedy is to join again. It is reported + * rather than repaired: deleting the row on a decrypt failure would throw away a + * membership that a transient keystore problem might have made unreadable only for a + * moment. + */ + UNREADABLE, + } - try { - final byte[] plain = this.cryptography.decrypt( - AppCryptographyUtil.AppEncryptedData.fromFlattened(encrypted), - KEYSTORE_ALIAS_KEYCHAIN); - final JSONObject json = new JSONObject(new String(plain, StandardCharsets.UTF_8)); - - return Optional.of(new KeychainMembership( - json.getString(FIELD_PEER), - json.getString(FIELD_ENTROPY), - json.getString(FIELD_PASSCODE), - json.optString(FIELD_LABEL, ""), - json.optInt(FIELD_SHARES, 0))); - } catch (Exception e) { - // **Reported as absent rather than thrown.** A membership that cannot be read is - // a membership this app cannot use, and the recovery is the same as never having - // joined: ask for a passcode and join again. Throwing here would take down the - // screen instead, on a path the user cannot do anything about. - Log.e(TAG, "The stored keychain membership could not be read", e); - return Optional.empty(); + /** + * Which of the three situations this device is in. + * + *

Prefer {@link #get()} where only a usable membership matters; use this where the + * difference between "never connected" and "connected but broken" changes what the user is + * told. + */ + public Observable state() { + return Observable.fromPublisher(this.store.data()).map(preferences -> { + if (preferences.get(KEYCHAIN_MEMBERSHIP) == null) { + return MembershipState.NONE; } + return this.readFrom(preferences).isPresent() + ? MembershipState.HELD : MembershipState.UNREADABLE; }); } + /** The membership, or empty when this app has not joined - which is the ordinary first run. */ + public Observable> get() { + return Observable.fromPublisher(this.store.data()).map(this::readFrom); + } + + private Optional readFrom(final Preferences preferences) { + final byte[] encrypted = preferences.get(KEYCHAIN_MEMBERSHIP); + if (encrypted == null) { + return Optional.empty(); + } + + try { + final byte[] plain = this.cryptography.decrypt( + AppCryptographyUtil.AppEncryptedData.fromFlattened(encrypted), + KEYSTORE_ALIAS_KEYCHAIN); + final JSONObject json = new JSONObject(new String(plain, StandardCharsets.UTF_8)); + + return Optional.of(new KeychainMembership( + json.getString(FIELD_PEER), + json.getString(FIELD_ENTROPY), + json.getString(FIELD_PASSCODE), + json.optString(FIELD_LABEL, ""), + json.optInt(FIELD_SHARES, 0))); + } catch (Exception e) { + // **Reported as absent rather than thrown.** A membership that cannot be read is + // a membership this app cannot use, and the recovery is the same as never having + // joined: ask for a passcode and join again. Throwing here would take down the + // screen instead, on a path the user cannot do anything about. + Log.e(TAG, "The stored keychain membership could not be read", e); + return Optional.empty(); + } + } + /** * Store a membership, and refuse to report success unless it is really stored. * diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index baec1a6e..85d91df1 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -354,4 +354,8 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Wie lange ein Tag nicht zu hören sein muss, bevor die App prüft, ob du es zurückgelassen hast. Kürzer warnt früher und prüft öfter. Alarmton Standard-Alarmton + Apple-Konto erneut verbinden + Diese App hat eine Verbindung zu deinem Apple-Konto gespeichert, kann sie aber nicht mehr lesen. Die dafür nötigen Schlüssel liegen im sicheren Speicher dieses Geräts und lassen sich nicht wiederherstellen – die Verbindung muss also neu hergestellt werden. + +Deine Tags und ihr Standortverlauf sind davon nicht betroffen, und aus deinem Apple-Konto wurde nichts entfernt. \ 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 a7709fbb..cf25b2cb 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -354,4 +354,8 @@ You can set this up now, or any time later from Settings. How long a tag has to go unheard before the app checks whether you have left it behind. Shorter catches you sooner and checks more often. Alarm sound Default alarm sound + Reconnect your Apple account + This app has a connection to your Apple account saved, but can no longer read it. The keys it needs are kept in this device\'s secure storage, and they cannot be recovered — so the connection has to be made again. + +Your tags and their location history are not affected, and nothing was removed from your Apple account. \ 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 bf8960e2..f19be190 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -354,4 +354,8 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.< Durée pendant laquelle un tag doit rester inaudible avant que l\'application vérifie si vous l\'avez oublié. Plus court avertit plus tôt et vérifie plus souvent. Son de l\'alarme Son d\'alarme par défaut + Reconnectez votre compte Apple + Cette application a une connexion à votre compte Apple enregistrée, mais ne parvient plus à la lire. Les clés nécessaires sont conservées dans le stockage sécurisé de cet appareil et ne peuvent pas être récupérées : la connexion doit donc être refaite. + +Vos tags et leur historique de position ne sont pas touchés, et rien n’a été supprimé de votre compte Apple. \ 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 3d75f30d..79561466 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -354,4 +354,8 @@ タグの信号が途絶えてから、置き忘れを確認するまでの時間です。短いほど早く気づき、確認回数も増えます。 アラーム音 既定のアラーム音 + Apple アカウントを接続し直してください + このアプリには Apple アカウントとの接続が保存されていますが、読み取れなくなりました。必要な鍵はこの端末の安全な保管領域にあり、復元できません。そのため接続をやり直す必要があります。 + +タグとその位置履歴には影響がなく、Apple アカウントからは何も削除されていません。 \ 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 cd020eb4..049175cb 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -354,4 +354,8 @@ 태그 신호가 끊긴 뒤 물건을 두고 왔는지 확인하기까지의 시간입니다. 짧을수록 빨리 알아차리고 더 자주 확인합니다. 알람음 기본 알람음 + 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 e76dab4a..a508e2ff 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -354,4 +354,8 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen. Hoe lang een tag onhoorbaar moet blijven voordat de app controleert of je hem hebt laten liggen. Korter waarschuwt eerder en controleert vaker. Alarmgeluid Standaard alarmgeluid + Verbind je Apple-account opnieuw + Deze app heeft een verbinding met je Apple-account opgeslagen, maar kan die niet meer lezen. De benodigde sleutels staan in de beveiligde opslag van dit apparaat en zijn niet te herstellen — de verbinding moet dus opnieuw worden gemaakt. + +Je tags en hun locatiegeschiedenis blijven ongemoeid, en er is niets uit je Apple-account verwijderd. \ 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 291f186c..9ef523c9 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -354,4 +354,8 @@ Сколько метка должна молчать, прежде чем приложение проверит, не забыли ли вы её. Меньше — раньше предупреждение и чаще проверки. Звук будильника Стандартный звук будильника + Подключите учётную запись Apple заново + Приложение хранит подключение к вашей учётной записи Apple, но больше не может его прочитать. Нужные ключи находятся в защищённом хранилище этого устройства и не подлежат восстановлению — поэтому подключение придётся выполнить заново. + +Ваши метки и история их местоположений не затронуты, и из учётной записи Apple ничего не удалено. \ 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 0f987eb2..290ec416 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -354,4 +354,8 @@ 标签失去信号多久后,应用才检查你是否把它落下了。时间越短提醒越早,检查也越频繁。 报警声 默认报警声 + 请重新连接您的 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 bf4fa629..b6b6ffea 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -354,4 +354,8 @@ 標籤失去訊號多久後,應用程式才檢查你是否把它落下了。時間越短提醒越早,檢查也越頻繁。 警報聲 預設警報聲 + 請重新連接你的 Apple 帳戶 + 本應用程式儲存了與你的 Apple 帳戶的連線,但已無法讀取。所需的金鑰保存在本裝置的安全儲存空間中,且無法復原,因此必須重新建立連線。 + +你的標籤與其位置紀錄不受影響,Apple 帳戶中也沒有任何內容被移除。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a89c36db..898d6843 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -387,4 +387,8 @@ You can set this up now, or any time later from Settings. How long a tag has to go unheard before the app checks whether you have left it behind. Shorter catches you sooner and checks more often. Alarm sound Default alarm sound + Reconnect your Apple account + This app has a connection to your Apple account saved, but can no longer read it. The keys it needs are kept in this device\'s secure storage, and they cannot be recovered — so the connection has to be made again. + +Your tags and their location history are not affected, and nothing was removed from your Apple account. From bcb77699c8606d13e00bdf31cf9081fe63efc61d Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 29 Aug 2026 11:03:28 +0200 Subject: [PATCH 53/61] Tell a keystore key that has gone from data that will not open with its key Following @parawanderer: if it is unexpected, it should offer the bug report screen rather than an explanation. So the membership state is now three-way rather than two. KEYS_GONE is explainable and not this app's fault - the keystore and the app's files have different lifetimes, and an OS upgrade, a wiped keystore or a transfer tool that copied app data (and could never copy keystore keys) all leave exactly that. It gets the reconnect message. UNREADABLE is the key being present and the data still not opening, which nothing explains, so it goes to the report screen with its own cause line - the same place an unexplainable import failure goes. **And decryption no longer creates keys.** getKeyForAlias served encrypt and decrypt alike, so a missing key was quietly replaced with a fresh one that could not open anything already written. That turned a problem which might have been momentary into a permanent one, and put the alias back afterwards, so nothing could tell it had ever gone. Absence is now MissingKeystoreKeyException and the decrypt path never generates. Worth noting what this rules out for anyone reading later: the key is created with no setUserAuthenticationRequired and no setInvalidatedByBiometricEnrollment, so enrolling a fingerprint or changing the screen lock does not invalidate it. Those are the usual explanations and they do not apply here. Also widens the doNotTrackState guard to the managed-device task. The comment there claimed it was unaffected because managedDevice/ is not the directory Studio watches; that was wrong, and testEmulatorDebugAndroidTest failed with the identical MD5 hash error seven seconds in, having run nothing. Co-Authored-By: Claude Opus 5 --- app/build.gradle.kts | 14 ++-- .../TheICloudOfferAppearsOnceTest.java | 67 +++++++++++++++--- .../android/opentagviewer/MapsActivity.java | 17 ++++- .../db/MissingKeystoreKeyException.java | 27 ++++++++ .../db/repo/KeychainMembershipRepository.java | 69 +++++++++++++------ .../util/android/AppCryptographyUtil.java | 41 ++++++++++- app/src/main/res/values-de/strings.xml | 2 + 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 + app/src/main/res/values/strings.xml | 2 + 16 files changed, 219 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/db/MissingKeystoreKeyException.java diff --git a/app/build.gradle.kts b/app/build.gradle.kts index afd1dff8..b5daabe9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -569,13 +569,19 @@ dependencies { * talk to a device - so the caching this disables was never doing anything. Gradle's own error * suggests exactly this. * - * The managed-device task is untouched, because `managedDevice/` is not the directory Studio - * watches. + * **This used to exempt the managed-device task**, on the reasoning that `managedDevice/` is not + * the directory Studio watches. That was wrong: `testEmulatorDebugAndroidTest` failed with the + * identical `Failed to create MD5 hash for file content`, seven seconds in, having run nothing. + * Both tasks write results Studio may be holding, and neither can ever be up to date, so both + * are untracked now. */ -tasks.matching { it.name.startsWith("connected") && it.name.endsWith("AndroidTest") } +tasks.matching { + (it.name.startsWith("connected") || it.name.startsWith("testEmulator")) + && it.name.endsWith("AndroidTest") +} .configureEach { doNotTrackState( - "Android Studio holds the connected results directory open on Windows, and an" + + "Android Studio holds the instrumented results directories open on Windows, and an" + " instrumented run is never up to date regardless.", ) } diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/TheICloudOfferAppearsOnceTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/TheICloudOfferAppearsOnceTest.java index 2fb6baaf..7aade8a7 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/TheICloudOfferAppearsOnceTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/TheICloudOfferAppearsOnceTest.java @@ -40,6 +40,7 @@ import dev.wander.android.opentagviewer.python.AppDependencies; import dev.wander.android.opentagviewer.Eventually; import dev.wander.android.opentagviewer.FetchFromICloudActivity; +import dev.wander.android.opentagviewer.ui.error.ErrorReportActivity; import dev.wander.android.opentagviewer.MapsActivity; import dev.wander.android.opentagviewer.R; import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; @@ -285,9 +286,10 @@ public void theOfferSurvivesAResumeWhileItIsOnScreen() { * except a Settings item they have no reason to open. */ @Test - public void anUnreadableConnectionAsksThemToReconnectRatherThanOfferingSetup() { + public void aConnectionWhoseKeyHasGoneAsksThemToReconnect() { this.settingsWhere(settings -> settings.setAnisetteMode(UserSettings.ANISETTE_LOCAL)); - this.givenAStoredMembershipThatCannotBeRead(); + this.givenAConnection(); + this.andThenItsKeystoreKeyDisappears(); this.openTheMap(); @@ -299,18 +301,65 @@ public void anUnreadableConnectionAsksThemToReconnectRatherThanOfferingSetup() { } /** - * Bytes under the membership key that are not a ciphertext this app can open. + * And the same situation with the key still present is a bug, so it offers a report. * - *

Written straight into the store rather than through {@code store()}, because the point - * is a value that decryption fails on - which is what a rotated or lost keystore key leaves - * behind, and there is no way to ask the real writer to produce one. + *

The distinction @parawanderer asked for. A key that has gone is somebody's device - an + * OS upgrade, a wiped keystore, a transfer tool that copied app data and could not copy + * keystore keys - and the useful thing to say is "connect it again". A key that is right + * there and still does not open the data is not explainable by any of that, so an + * explanation would be an apology for something the user cannot act on, and the report is + * what is actually worth offering. */ - private void givenAStoredMembershipThatCannotBeRead() { + @Test + public void aConnectionThatWillNotOpenWithItsOwnKeyOffersABugReport() { + this.settingsWhere(settings -> settings.setAnisetteMode(UserSettings.ANISETTE_LOCAL)); + this.givenAConnection(); + this.butItsStoredBytesAreDamaged(); + + this.openTheMap(); + + Eventually.check(() -> intended(hasComponent(ErrorReportActivity.class.getName()))); + + assertFalse("a fault must not spend the one-time offer either", + this.theOfferHasBeenMade()); + } + + /** A real membership, written through the real writer, so the ciphertext is genuine. */ + private void givenAConnection() { + this.memberships.store(new KeychainMembership( + "{\"peer\":\"invented\"}", "entropy", "PASS-CODE-HERE", "This phone", 1)) + .blockingAwait(); + } + + /** + * Take the keystore key away and leave the data behind. + * + *

Deleted rather than corrupted, because that is the actual shape of the situation: the + * keystore and this app's files have different lifetimes, and it is always the key that + * goes. Done explicitly rather than by writing junk and hoping the alias happens not to + * exist - an earlier test in the run may well have created it, which would make this assert + * the opposite case by accident. + */ + private void andThenItsKeystoreKeyDisappears() { + try { + final java.security.KeyStore keyStore = + java.security.KeyStore.getInstance("AndroidKeyStore"); + keyStore.load(null); + keyStore.deleteEntry( + dev.wander.android.opentagviewer.AppKeyStoreConstants.KEYSTORE_ALIAS_KEYCHAIN); + } catch (final Exception e) { + throw new IllegalStateException("could not take the keystore key away", e); + } + } + + /** Keep the key, ruin the ciphertext: the combination that should not be possible. */ + private void butItsStoredBytesAreDamaged() { UserAuthDataStore.getInstance(this.context).updateDataAsync(preferences -> { final androidx.datastore.preferences.core.MutablePreferences mutable = preferences.toMutablePreferences(); - mutable.set(dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore - .KEYCHAIN_MEMBERSHIP, new byte[] {1, 2, 3, 4, 5, 6, 7, 8}); + mutable.set(UserAuthDataStore.KEYCHAIN_MEMBERSHIP, + new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32}); return io.reactivex.rxjava3.core.Single.just(mutable); }).blockingGet(); } 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 6e202fc7..f2b59a2d 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -2033,11 +2033,24 @@ private void respondToTheMembershipState(final KeychainMembershipRepository.Memb case HELD: // Already reading the account. Nothing to offer and nothing wrong. return; - case UNREADABLE: - Log.w(TAG, "The stored keychain membership cannot be read, so telling them to" + case KEYS_GONE: + // Explainable and not a fault: the keystore key went away and the data it wrote + // stayed. Nothing to report, and something for them to do. + Log.w(TAG, "The keystore key for the membership is gone, so telling them to" + " connect the account again rather than offering it as though new"); this.askThemToReconnectTheAccount(); return; + case UNREADABLE: + // **The key is present and it still does not open the data, which is a bug.** + // Nothing a user did causes this, so an explanation would be an apology for + // something they cannot act on - the report is the useful thing to offer, and + // it is the same screen an unexplainable import failure goes to. + Log.e(TAG, "The membership is stored and its key is present, and it still does" + + " not decrypt - sending them to make a report"); + this.startActivity(ErrorReportActivity.intentFor(this, + getString(R.string.error_report_cause_membership_unreadable), + R.string.error_report_body_membership)); + return; case NONE: default: this.offerICloudSetupTo(false); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/MissingKeystoreKeyException.java b/app/src/main/java/dev/wander/android/opentagviewer/db/MissingKeystoreKeyException.java new file mode 100644 index 00000000..ceea4fa5 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/MissingKeystoreKeyException.java @@ -0,0 +1,27 @@ +package dev.wander.android.opentagviewer.db; + +/** + * Something encrypted is still here, and the key that opens it is not. + * + *

Its own type because it is the one decryption failure that is not a bug. The keys + * live in the Android keystore and the ciphertext lives in this app's data, and those have + * different lifetimes: an OS upgrade, a keystore that got wiped, or a device-transfer tool that + * copied app data - which can never copy keystore keys - all leave exactly this. There is no + * repair; what was written is gone, and the remedy is whatever re-establishes it. + * + *

Everything else that fails to decrypt is unexplained: the key is present, was used to write + * the data, and no longer opens it. That is worth a bug report, and telling the two apart is why + * this class exists rather than one message covering both. + * + *

And the key is never re-created on the decrypt path. It used to be - the same + * "fetch or generate" helper served encrypt and decrypt - so a missing key was quietly replaced + * with a new one that could not open anything already written. That turned a problem which might + * have been momentary into a permanent one, and destroyed the evidence on the way: the alias + * existed again afterwards, so nothing could tell that it had ever gone. + */ +public class MissingKeystoreKeyException extends AppCryptographyException { + + public MissingKeystoreKeyException(final String message) { + super(message); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/KeychainMembershipRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/KeychainMembershipRepository.java index e3ea49a9..72d7599a 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/KeychainMembershipRepository.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/KeychainMembershipRepository.java @@ -16,6 +16,7 @@ import dev.wander.android.opentagviewer.python.icloud.KeychainMembership; import dev.wander.android.opentagviewer.db.AppCryptographyException; +import dev.wander.android.opentagviewer.db.MissingKeystoreKeyException; import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Observable; @@ -70,13 +71,24 @@ public enum MembershipState { /** Joined, and the keys are usable. */ HELD, /** - * Joined, and the stored keys cannot be decrypted. + * Joined, and the keystore key that opened it is gone. * - *

The keys are the only copy of the means to use a peer that exists on the user's - * account, so this is not recoverable here - the remedy is to join again. It is reported - * rather than repaired: deleting the row on a decrypt failure would throw away a - * membership that a transient keystore problem might have made unreadable only for a - * moment. + *

Explainable, and not this app's fault. The keys live in the Android keystore + * and the ciphertext lives in app data, and those have different lifetimes - an OS + * upgrade, a wiped keystore, a device-transfer tool that copied app data and could never + * copy keystore keys. Nothing here can recover it; the remedy is to join again. + * + *

Reported rather than repaired: deleting the row on a failure would throw away a + * membership that a momentary keystore problem had made unreadable for a moment. + */ + KEYS_GONE, + /** + * Joined, the key is right there, and it still does not open the data. + * + *

That is not explainable, so it is a bug. The key present and the ciphertext + * present and the two not matching means something wrote or stored it wrongly, and the + * user is owed a bug report rather than an apology - see how {@code MapsActivity} routes + * this one to the report screen while {@link #KEYS_GONE} gets an explanation. */ UNREADABLE, } @@ -90,11 +102,23 @@ public enum MembershipState { */ public Observable state() { return Observable.fromPublisher(this.store.data()).map(preferences -> { - if (preferences.get(KEYCHAIN_MEMBERSHIP) == null) { + final byte[] encrypted = preferences.get(KEYCHAIN_MEMBERSHIP); + if (encrypted == null) { return MembershipState.NONE; } - return this.readFrom(preferences).isPresent() - ? MembershipState.HELD : MembershipState.UNREADABLE; + + try { + this.decode(encrypted); + return MembershipState.HELD; + } catch (final MissingKeystoreKeyException keyIsGone) { + Log.w(TAG, "The keystore key for the membership is gone, so it cannot be read", + keyIsGone); + return MembershipState.KEYS_GONE; + } catch (final Exception unexplained) { + Log.e(TAG, "The membership is stored and its key is present, and it still does" + + " not decrypt", unexplained); + return MembershipState.UNREADABLE; + } }); } @@ -103,6 +127,21 @@ public Observable> get() { return Observable.fromPublisher(this.store.data()).map(this::readFrom); } + /** Decrypt and parse, or throw. {@link #state()} is the caller that wants to know why. */ + private KeychainMembership decode(final byte[] encrypted) throws Exception { + final byte[] plain = this.cryptography.decrypt( + AppCryptographyUtil.AppEncryptedData.fromFlattened(encrypted), + KEYSTORE_ALIAS_KEYCHAIN); + final JSONObject json = new JSONObject(new String(plain, StandardCharsets.UTF_8)); + + return new KeychainMembership( + json.getString(FIELD_PEER), + json.getString(FIELD_ENTROPY), + json.getString(FIELD_PASSCODE), + json.optString(FIELD_LABEL, ""), + json.optInt(FIELD_SHARES, 0)); + } + private Optional readFrom(final Preferences preferences) { final byte[] encrypted = preferences.get(KEYCHAIN_MEMBERSHIP); if (encrypted == null) { @@ -110,17 +149,7 @@ private Optional readFrom(final Preferences preferences) { } try { - final byte[] plain = this.cryptography.decrypt( - AppCryptographyUtil.AppEncryptedData.fromFlattened(encrypted), - KEYSTORE_ALIAS_KEYCHAIN); - final JSONObject json = new JSONObject(new String(plain, StandardCharsets.UTF_8)); - - return Optional.of(new KeychainMembership( - json.getString(FIELD_PEER), - json.getString(FIELD_ENTROPY), - json.getString(FIELD_PASSCODE), - json.optString(FIELD_LABEL, ""), - json.optInt(FIELD_SHARES, 0))); + return Optional.of(this.decode(encrypted)); } catch (Exception e) { // **Reported as absent rather than thrown.** A membership that cannot be read is // a membership this app cannot use, and the recovery is the same as never having diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/android/AppCryptographyUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/util/android/AppCryptographyUtil.java index 55b9a09e..14c0ef8d 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/android/AppCryptographyUtil.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/android/AppCryptographyUtil.java @@ -10,6 +10,7 @@ import static javax.crypto.Cipher.ENCRYPT_MODE; import android.security.keystore.KeyGenParameterSpec; +import android.util.Log; import android.util.Pair; import java.io.IOException; @@ -31,11 +32,14 @@ import javax.crypto.spec.GCMParameterSpec; import dev.wander.android.opentagviewer.db.AppCryptographyException; +import dev.wander.android.opentagviewer.db.MissingKeystoreKeyException; import lombok.Getter; import lombok.NonNull; import lombok.RequiredArgsConstructor; public final class AppCryptographyUtil { + + private static final String TAG = AppCryptographyUtil.class.getSimpleName(); // https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec#example:-aes-key-for-encryptiondecryption-in-gcm-mode // https://developer.android.com/privacy-and-security/cryptography // https://developer.android.com/reference/android/security/keystore/KeyProtection#example:-aes-key-for-encryptiondecryption-in-gcm-mode @@ -71,9 +75,23 @@ public synchronized AppEncryptedData encrypt(final byte[] dataToEncrypt, final S } } + /** + * Decryption never creates a key. A key made now cannot open anything written before, + * so generating one here can only turn a missing key into a failed decrypt - while putting + * the alias back, which hides the fact that it was ever gone. Absence is reported as + * {@link MissingKeystoreKeyException}, which is the one decryption failure that is somebody's + * device rather than this app's bug. + */ public synchronized byte[] decrypt(final byte[] dataToDecrypt, final byte[] iv, final String keystoreAlias) { + final SecretKey existing = this.existingKeyForAlias(keystoreAlias); + if (existing == null) { + throw new MissingKeystoreKeyException( + "There is no keystore key under " + keystoreAlias + " any more, so what was" + + " encrypted with it cannot be read"); + } + try { - SecretKey key = this.getKeyForAlias(keystoreAlias); + SecretKey key = existing; Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(DECRYPT_MODE, key, new GCMParameterSpec(AES_GMC_TAG_SIZE * 8, iv)); return cipher.doFinal(dataToDecrypt); @@ -86,6 +104,27 @@ public synchronized byte[] decrypt(@NonNull final AppEncryptedData data, final S return decrypt(data.getCipherText(), data.getIv(), keystoreAlias); } + /** + * The key under this alias, or null if there is not one. Never creates. + * + *

A keystore that cannot be opened at all is reported as absent too: it is the same + * situation for a caller, and throwing out of here would take down a screen on a path + * nobody can act on. + */ + private synchronized SecretKey existingKeyForAlias(@NonNull final String keystoreAlias) { + try { + final KeyStore keyStore = KeyStore.getInstance(ANDROID_KEYSTORE); + keyStore.load(null); + + final Key entry = keyStore.getKey(keystoreAlias, null); + return entry instanceof SecretKey ? (SecretKey) entry : null; + } catch (final Exception keystoreUnavailable) { + Log.w(TAG, "Could not read the keystore looking for " + keystoreAlias, + keystoreUnavailable); + return null; + } + } + private synchronized SecretKey getKeyForAlias(@NonNull final String keystoreAlias) throws KeyStoreException, CertificateException, IOException, NoSuchAlgorithmException, UnrecoverableEntryException, NoSuchProviderException, InvalidAlgorithmParameterException { KeyStore keyStore = KeyStore.getInstance(ANDROID_KEYSTORE); keyStore.load(null); diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 85d91df1..097c8c10 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -358,4 +358,6 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Diese App hat eine Verbindung zu deinem Apple-Konto gespeichert, kann sie aber nicht mehr lesen. Die dafür nötigen Schlüssel liegen im sicheren Speicher dieses Geräts und lassen sich nicht wiederherstellen – die Verbindung muss also neu hergestellt werden. Deine Tags und ihr Standortverlauf sind davon nicht betroffen, und aus deinem Apple-Konto wurde nichts entfernt. + Die gespeicherte Verbindung zum Apple-Konto ließ sich nicht entschlüsseln, obwohl ihr Schlüssel noch vorhanden ist + Die Verbindung zu deinem Apple-Konto liegt auf diesem Gerät, der Schlüssel dazu ist noch da, und sie lässt sich trotzdem nicht mehr öffnen – das sollte nicht möglich sein.\n\nDeshalb lohnt sich eine Meldung, statt es einfach neu einzurichten. Deine Tags und ihr Standortverlauf sind nicht betroffen, und aus deinem Apple-Konto wurde nichts entfernt. \ 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 cf25b2cb..92c09e16 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -358,4 +358,6 @@ You can set this up now, or any time later from Settings. This app has a connection to your Apple account saved, but can no longer read it. The keys it needs are kept in this device\'s secure storage, and they cannot be recovered — so the connection has to be made again. Your tags and their location history are not affected, and nothing was removed from your Apple account. + The saved Apple account connection could not be decrypted, although its key is still present + The connection to your Apple account is stored on this device, the key that unlocks it is still here, and it no longer opens — which should not be possible.\n\nThat makes it worth reporting rather than just redoing. Your tags and their location history are not affected, and nothing was removed from your Apple account. \ 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 f19be190..a12c6a2a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -358,4 +358,6 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.< Cette application a une connexion à votre compte Apple enregistrée, mais ne parvient plus à la lire. Les clés nécessaires sont conservées dans le stockage sécurisé de cet appareil et ne peuvent pas être récupérées : la connexion doit donc être refaite. Vos tags et leur historique de position ne sont pas touchés, et rien n’a été supprimé de votre compte Apple. + La connexion au compte Apple enregistrée n’a pas pu être déchiffrée, bien que sa clé soit toujours présente + La connexion à votre compte Apple est enregistrée sur cet appareil, la clé qui l’ouvre est toujours là, et elle ne s’ouvre plus : cela ne devrait pas être possible.\n\nCela vaut donc la peine d’être signalé plutôt que simplement refait. Vos tags et leur historique de position ne sont pas touchés, et rien n’a été supprimé de votre compte Apple. \ 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 79561466..b8e1325b 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -358,4 +358,6 @@ このアプリには Apple アカウントとの接続が保存されていますが、読み取れなくなりました。必要な鍵はこの端末の安全な保管領域にあり、復元できません。そのため接続をやり直す必要があります。 タグとその位置履歴には影響がなく、Apple アカウントからは何も削除されていません。 + 保存された Apple アカウント接続を復号できませんでした。鍵は残っています + Apple アカウントへの接続はこの端末に保存されていて、それを開く鍵も残っているのに、開けなくなりました。本来ありえないことです。\n\nそのため、設定し直すだけでなく報告する価値があります。タグとその位置履歴には影響がなく、Apple アカウントからは何も削除されていません。 \ 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 049175cb..9e8545af 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -358,4 +358,6 @@ 이 앱에 Apple 계정 연결이 저장되어 있지만 더 이상 읽을 수 없습니다. 필요한 키는 이 기기의 보안 저장소에 있으며 복구할 수 없으므로 연결을 다시 만들어야 합니다. 태그와 위치 기록에는 영향이 없으며, Apple 계정에서 삭제된 것도 없습니다. + 저장된 Apple 계정 연결을 복호화하지 못했습니다. 키는 그대로 남아 있습니다 + Apple 계정 연결은 이 기기에 저장되어 있고 이를 여는 키도 그대로 있는데 더 이상 열리지 않습니다. 원래는 있을 수 없는 일입니다.\n\n그래서 그냥 다시 설정하기보다 신고할 가치가 있습니다. 태그와 위치 기록에는 영향이 없으며, 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 a508e2ff..59c2b938 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -358,4 +358,6 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen. Deze app heeft een verbinding met je Apple-account opgeslagen, maar kan die niet meer lezen. De benodigde sleutels staan in de beveiligde opslag van dit apparaat en zijn niet te herstellen — de verbinding moet dus opnieuw worden gemaakt. Je tags en hun locatiegeschiedenis blijven ongemoeid, en er is niets uit je Apple-account verwijderd. + De opgeslagen verbinding met het Apple-account kon niet worden ontsleuteld, terwijl de sleutel er nog wel is + De verbinding met je Apple-account staat op dit apparaat, de sleutel die hem opent is er nog, en toch gaat hij niet meer open — dat hoort niet te kunnen.\n\nDaarom is dit het melden waard in plaats van het gewoon opnieuw te doen. Je tags en hun locatiegeschiedenis blijven ongemoeid, en er is niets uit je Apple-account verwijderd. \ 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 9ef523c9..2cddefb4 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -358,4 +358,6 @@ Приложение хранит подключение к вашей учётной записи Apple, но больше не может его прочитать. Нужные ключи находятся в защищённом хранилище этого устройства и не подлежат восстановлению — поэтому подключение придётся выполнить заново. Ваши метки и история их местоположений не затронуты, и из учётной записи Apple ничего не удалено. + Сохранённое подключение к учётной записи Apple не удалось расшифровать, хотя его ключ на месте + Подключение к вашей учётной записи Apple хранится на этом устройстве, ключ к нему на месте, и оно всё равно не открывается — так быть не должно.\n\nПоэтому об этом стоит сообщить, а не просто настроить заново. Ваши метки и история их местоположений не затронуты, и из учётной записи Apple ничего не удалено. \ 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 290ec416..e6c7259b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -358,4 +358,6 @@ 本应用保存了与你的 Apple 账户的连接,但已无法读取。所需的密钥保存在本设备的安全存储中,且无法恢复,因此需要重新建立连接。 你的标签及其位置历史不受影响,Apple 账户中也没有任何内容被移除。 + 已保存的 Apple 账户连接无法解密,但其密钥仍然存在 + 与你的 Apple 账户的连接就保存在本设备上,解开它的密钥也还在,却打不开了——这本不该发生。\n\n因此这值得报告,而不只是重做一次。你的标签及其位置历史不受影响,Apple 账户中也没有任何内容被移除。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index b6b6ffea..23195d49 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -358,4 +358,6 @@ 本應用程式儲存了與你的 Apple 帳戶的連線,但已無法讀取。所需的金鑰保存在本裝置的安全儲存空間中,且無法復原,因此必須重新建立連線。 你的標籤與其位置紀錄不受影響,Apple 帳戶中也沒有任何內容被移除。 + 已儲存的 Apple 帳戶連線無法解密,但其金鑰仍然存在 + 與你的 Apple 帳戶的連線就儲存在本裝置上,解開它的金鑰也還在,卻打不開了——這本不該發生。\n\n因此這值得回報,而不只是重做一次。你的標籤與其位置紀錄不受影響,Apple 帳戶中也沒有任何內容被移除。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 898d6843..73ff7a65 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -391,4 +391,6 @@ You can set this up now, or any time later from Settings. This app has a connection to your Apple account saved, but can no longer read it. The keys it needs are kept in this device\'s secure storage, and they cannot be recovered — so the connection has to be made again. Your tags and their location history are not affected, and nothing was removed from your Apple account. + The saved Apple account connection could not be decrypted, although its key is still present + The connection to your Apple account is stored on this device, the key that unlocks it is still here, and it no longer opens — which should not be possible.\n\nThat makes it worth reporting rather than just redoing. Your tags and their location history are not affected, and nothing was removed from your Apple account. From 390416a40167d68bd31cceb8eb6069bacde92b06 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sun, 30 Aug 2026 07:42:05 +0200 Subject: [PATCH 54/61] Match the sighting test to the report-returning recorder recordLocalSighting went from Observable to Observable> so a caller can draw what was just written without reading it back, and this test's helper still declared boolean. That is a compile error in the androidTest source set, which is why the instrumented job on #139 failed after 1m38s having run no tests at all - the suite never built. "Tests are failing" and "the tests could not be compiled" look identical from the checks list. Presence is the same answer the boolean was: a row was written, or the sighting did not earn one. Every assertion in the class is unchanged. The method's own @return went stale in the same edit and said "true when a row was written". Fixed here rather than left, since it is the sentence anybody writing the next caller will read. Co-Authored-By: Claude Opus 5 --- .../db/repo/WritingDownWhereATagWasHeardTest.java | 8 +++++++- .../android/opentagviewer/db/repo/BeaconRepository.java | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) 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 index c2671218..59b53dee 100644 --- 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 @@ -65,9 +65,15 @@ public void closeIt() { this.db.close(); } + /** + * @return whether the sighting earned a row. {@code recordLocalSighting} used to answer that + * with a boolean and now hands back the report it wrote, so presence is the same + * answer - every assertion here is still about the decision, not the row. + */ 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(); + .blockingFirst() + .isPresent(); } private List allReports() { 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 4480e0e5..5eca2f8f 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 @@ -659,7 +659,9 @@ public Completable storeLastSighting( *

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. + * @return the report that was written, or empty when this sighting did not earn a row - so a + * caller can log or test the decision, and can also draw what was just recorded + * without reading it back. */ public Observable> recordLocalSighting( final String beaconId, From 664141344c49bb4d22878e6d4b8ce194f697af51 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sun, 30 Aug 2026 09:28:13 +0200 Subject: [PATCH 55/61] Make a stalled run say so, instead of looking like a slow one A run wedged on a single test for 25 minutes and nothing noticed. The watcher already had the answer - `watch` mode reports a log that stops growing after eight minutes - and I hand-rolled a sleep loop around `--once` instead, which waits for a terminal line and is blind to a run that never produces one. It then hit its own window and exited 0 printing nothing, which reads exactly like success. @parawanderer spotted it at 1h22m; the tool would have said so at 8 minutes. So the skill now shows that wrong loop by name and what it cost, with the rule underneath: watch progress, not just completion, and a watcher that can exit silently is not a watcher. diagnose_stall also gains the third hang. Its advice was "suspect the device, check logcat -b crash", which is wrong for this one: the process is alive, the crash buffer is empty, and an activity has finished with nothing to replace it, so Espresso's root picker retries on a thirty-second backoff forever. Both commands that identify it are in the message now - which test never finished, and whether RootViewPicker is spinning. Co-Authored-By: Claude Opus 5 --- .claude/skills/watch-gradle-tests/SKILL.md | 25 +++++++++++++++++++ .../skills/watch-gradle-tests/watch_tests.py | 9 +++++++ 2 files changed, 34 insertions(+) diff --git a/.claude/skills/watch-gradle-tests/SKILL.md b/.claude/skills/watch-gradle-tests/SKILL.md index 6ee0fbc3..85cf0d35 100644 --- a/.claude/skills/watch-gradle-tests/SKILL.md +++ b/.claude/skills/watch-gradle-tests/SKILL.md @@ -22,6 +22,31 @@ python .claude/skills/watch-gradle-tests/watch_tests.py tmp/run.log Each line it prints is one event: `FAILED .` as each failure appears, `STALLED …` if the log stops growing, and `FINISHED` + `VERDICT` at the end. +### Never wrap `--once` in your own sleep loop + +That is the shape step 3 exists to replace, and it looks close enough to right to pass review: + +```bash +# WRONG - and this exact loop cost 1h22m +for i in $(seq 1 110); do + grep -qE "BUILD SUCCESSFUL|BUILD FAILED" tmp/run.log && { ...--once; break; } + sleep 20 +done +``` + +It waits for a **terminal line** and nothing else, so it is blind to the run stopping without +one — which is the failure worth catching. A suite hung 25 minutes on a single test produced no +new output and no verdict, so the loop sat silent, then hit its own limit and exited **0 with no +output at all**: indistinguishable from success. Meanwhile `watch` mode would have said +`STALLED no output for 8 min, at 424/687` seventeen minutes earlier. + +Two rules follow, and they are the same rule twice: + +- **Watch progress, not just completion.** "Still running" and "wedged" look identical unless + something is measuring the gap between outputs. +- **A watcher that can exit silently is not a watcher.** If yours can end without printing, + make the last thing it does print where the run got to. + ## Why this exists Three hand-written monitors in one afternoon each matched **nothing**, and each looked like a diff --git a/.claude/skills/watch-gradle-tests/watch_tests.py b/.claude/skills/watch-gradle-tests/watch_tests.py index c45c7392..38ed2c60 100644 --- a/.claude/skills/watch-gradle-tests/watch_tests.py +++ b/.claude/skills/watch-gradle-tests/watch_tests.py @@ -165,6 +165,15 @@ def diagnose_stall(log: str, age: float) -> list[str]: elif done: lines.append("STALLED tests had been running, so suspect the device rather than the " "lock: adb logcat -b crash") + # **A live process with nothing resumed is the third hang, and it is not a crash.** + # An activity finished itself - a session that would not restore, or a started activity + # that a test had stubbed - and Espresso's root picker then retries on a 30-second + # backoff forever, printing only "No activity currently resumed". The crash buffer is + # empty and the process is alive, so both of the checks above say nothing is wrong. + lines.append("STALLED if the crash buffer is empty, ask what is on screen: " + "adb logcat -d -s TestRunner:I | tail -3 (which test never finished) and " + "adb logcat -d | grep RootViewPicker (an activity finished and nothing " + "replaced it)") return lines From 65c4276e6540e32f78c7c46a76b84875a45ed7e4 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sun, 30 Aug 2026 18:13:55 +0200 Subject: [PATCH 56/61] Grant the Bluetooth permissions before the map opens, like location The instrumented suite hung indefinitely on ImportingAZipPutsTagsOnTheMapTest and failed TheWholeAppJourneyTest, and CI stopped at 423/687 and was killed by timeout-minutes at 45 minutes having reported nothing for the last eight. startWatchingForNearbyTags asks for BLUETOOTH_SCAN and BLUETOOTH_CONNECT as the map opens - deliberately, so the badges on the cards work without somebody pressing ring first - and its own comment says the system dialog pauses the activity. This fixture granted only the two location permissions, so the map opened behind that dialog, nothing was resumed, and Espresso's root picker span on a thirty-second backoff. grantLocationUpFront already existed for exactly this, for location, and its javadoc had already predicted this: "a new test that used the fixture and forgot the rule would hit the same six-minute wall". It is worse than six minutes now. There is no per-test timeout - removed on purpose, it cost about three minutes a run - so nothing stops it; the test does not fail, it never ends, and the job's own limit is what finally kills it. Taken from BlePermissions.required() rather than named again here, since which permissions those are depends on the API level. Naming them twice is how the copy that is wrong goes unnoticed. Not mine, and checked before saying so: the same class hangs identically on this branch with only the compile fix applied and none of my other commits. Co-Authored-By: Claude Opus 5 --- .../ui/maps/AMapWithTagsOnIt.java | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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 00635f71..fea197ce 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 @@ -21,6 +21,7 @@ import java.util.List; import java.util.Map; +import dev.wander.android.opentagviewer.ble.BlePermissions; import dev.wander.android.opentagviewer.DeviceStateGuard; import dev.wander.android.opentagviewer.MapsActivity; import dev.wander.android.opentagviewer.R; @@ -258,13 +259,29 @@ public AMapWithTagsOnIt seed(final String... names) { *

Done here rather than with a {@code GrantPermissionRule} in each test, because a rule * is per-class and this is a property of arranging the map at all - a new test that used the * fixture and forgot the rule would hit the same six-minute wall. + * + *

Bluetooth is on this list for the same reason, and it arrived later. + * {@code startWatchingForNearbyTags} asks for the scan permissions as the map opens - so the + * badges on the cards work without somebody pressing ring first - and its own comment notes + * that the system dialog pauses the activity. With only location granted, the map opened + * behind that dialog and the root picker span on "No activity currently resumed" until the + * job's own limit: not six minutes this time but forty-five, because there is no per-test + * timeout to stop it. It never failed, it just never ended. + * + *

Taken from {@link BlePermissions#required()} rather than named here, because which + * permissions those are depends on the API level - {@code BLUETOOTH_SCAN} and + * {@code BLUETOOTH_CONNECT} from S, and fine location before it. Naming them twice is how + * the copy that is wrong goes unnoticed. */ private void grantLocationUpFront() { final String packageName = this.context.getPackageName(); - for (final String permission : new String[] { + final List needed = new ArrayList<>(List.of( android.Manifest.permission.ACCESS_FINE_LOCATION, - android.Manifest.permission.ACCESS_COARSE_LOCATION}) { + android.Manifest.permission.ACCESS_COARSE_LOCATION)); + needed.addAll(List.of(BlePermissions.required())); + + for (final String permission : needed) { try { getInstrumentation().getUiAutomation() .grantRuntimePermission(packageName, permission); From fe30f4647e4ba7bffb4b08d2faf21465effbf072 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sun, 30 Aug 2026 18:44:31 +0200 Subject: [PATCH 57/61] Stop a scan the adapter already ended, instead of crashing on it stopScan raises IllegalStateException("BT Adapter is not turned ON") when Bluetooth went off while the watch was running, and this call is in the emitter's cancellable - so it runs during disposal, when there is no subscriber left to receive a throw. RxJava hands it to the global error handler and the process goes down. Not an exotic path: turn Bluetooth off with the map open, then leave the screen. MapsActivity.onPause -> stopWatchingForNearbyTags -> dispose -> crash. It took the whole instrumented run with it, reported as "Instrumentation run failed due to Process crashed" after 91 of 687 tests. The restart a few lines above already catches exactly this, for the same reason, and says so. The cancellable was the one place it did not. Nothing is lost by swallowing it: the adapter turning off is what stops a scan, so there is nothing left to stop. startScan is deliberately not touched. It is guarded by the scanner == null check that precedes it, and a throw there reaches the subscriber as onError rather than killing the process. Found by granting the Bluetooth permission to the test runner: without it the watcher returned early and never scanned, so no test ever reached this. Co-Authored-By: Claude Opus 5 --- .../opentagviewer/ble/NearbyTagWatcher.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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 902353b8..b6440ccc 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 @@ -311,7 +311,24 @@ public void onScanFailed(final int errorCode) { emitter.setCancellable(() -> { Log.d(TAG, "Stopped watching for nearby tags"); scanRefresh.dispose(); - scanner.stopScan(callback); + + // **Stopping a scan the adapter has already ended throws, and on this path a + // throw is fatal.** stopScan raises IllegalStateException("BT Adapter is not + // turned ON") when Bluetooth went off while we were watching - which is an + // ordinary thing for somebody to do - and a cancellable that throws during + // disposal has no subscriber left to receive it, so RxJava hands it to the + // global error handler and the process goes down. Not a crash on some exotic + // path either: turn Bluetooth off with the map open, then leave the screen. + // + // Nothing is lost by swallowing it. The adapter turning off is what stops a + // scan; there is no scan left to stop. Same reasoning as the restart above, + // which already catches this for the same reason. + try { + scanner.stopScan(callback); + } catch (final Exception bluetoothWentAway) { + Log.d(TAG, "The nearby scan had already ended with the adapter", + bluetoothWentAway); + } }); }).subscribeOn(Schedulers.io()); } From d71caf44e6545dd3162c75b24742652434dcadc0 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sun, 30 Aug 2026 18:44:31 +0200 Subject: [PATCH 58/61] Grant the app's permissions once, in the runner, not per test class Eight classes reach the map without the map fixture, and the map now asks for Bluetooth as it opens. One of them carried a GrantPermissionRule listing exactly the permissions that used to be enough. Nothing was wrong with any of them: they were written before the app asked for one more thing, and a rule is per-class, so there was no single place to add it. An ungranted permission does not fail a test here, it hangs the suite: the system dialog pauses the activity, Espresso finds nothing resumed, and its root picker retries on a thirty-second backoff. There is no per-test timeout, so nothing stops it - the run does not fail, it never ends, and CI's timeout-minutes: 45 is what kills it. Safe because nothing in this source set tests a refusal - no test asserts that a permission is requested, rationalised or denied, checked before writing it. The class says so, and warns that a future test about refusal has to revoke what it is about in its own setup. Also documents the log-file race in the watcher skill: reusing one filename lets a Monitor read the previous run's log, match its BUILD line and report a verdict for a run that has not started. It read as a stale APK here, which it was not. Full instrumented suite on this branch: 687 tests, 0 failed, 22 skipped - the first time it has run to completion. Co-Authored-By: Claude Opus 5 --- .claude/skills/watch-gradle-tests/SKILL.md | 17 +++++ app/build.gradle.kts | 5 +- .../GrantWhatTheAppAsksForRunner.java | 63 +++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/GrantWhatTheAppAsksForRunner.java diff --git a/.claude/skills/watch-gradle-tests/SKILL.md b/.claude/skills/watch-gradle-tests/SKILL.md index 85cf0d35..c7c1624f 100644 --- a/.claude/skills/watch-gradle-tests/SKILL.md +++ b/.claude/skills/watch-gradle-tests/SKILL.md @@ -22,6 +22,23 @@ python .claude/skills/watch-gradle-tests/watch_tests.py tmp/run.log Each line it prints is one event: `FAILED .` as each failure appears, `STALLED …` if the log stops growing, and `FINISHED` + `VERDICT` at the end. +### Give every run its own log file + +Reusing one name races the watcher against the run that is starting. Arm a Monitor while the +previous run's log is still on disk and it reads *that* — matches its `BUILD` line, prints its +verdict, and reports a finished run that has not started. The XML age in `VERDICT` is the only +hint, and "2 min old" looks perfectly current. + +That cost a wrong conclusion here: a stale verdict was read as the new run's, its crash trace +pointed at a line number the fix had already moved, and the obvious inference — "the APK did +not rebuild" — was wrong twice over. + +```bash +log=tmp/run-$(date +%H%M%S).log +./gradlew :app:testEmulatorDebugAndroidTest --console=plain > "$log" 2>&1 & +python .claude/skills/watch-gradle-tests/watch_tests.py "$log" +``` + ### Never wrap `--once` in your own sleep loop That is the shape step 3 exists to replace, and it looks close enough to right to pass review: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b5daabe9..baeb9eb9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -104,7 +104,10 @@ android { // field exists in both variants so code reading it compiles in both. buildConfigField("String", "BUILD_COMMIT", "null") - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // Grants the app's runtime permissions before the first test - see the class, which + // explains why an ungranted one hangs the suite rather than failing a test. + testInstrumentationRunner = + "dev.wander.android.opentagviewer.GrantWhatTheAppAsksForRunner" // **Do not add `timeout_msec` here.** It works - a hanging test fails at the cap with // its own name - but AndroidJUnitRunner pays for it per test, not per hang: with it set diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/GrantWhatTheAppAsksForRunner.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/GrantWhatTheAppAsksForRunner.java new file mode 100644 index 00000000..f9a75d89 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/GrantWhatTheAppAsksForRunner.java @@ -0,0 +1,63 @@ +package dev.wander.android.opentagviewer; + +import android.Manifest; + +import androidx.test.runner.AndroidJUnitRunner; + +import java.util.ArrayList; +import java.util.List; + +import dev.wander.android.opentagviewer.ble.BlePermissions; + +/** + * Grants the runtime permissions the app asks for on startup, before any test runs. + * + *

An ungranted permission does not fail a test here - it hangs the suite. The screens + * ask for what they need the moment they open, and a system permission dialog belongs to the + * permission controller rather than to this app: it takes focus, pauses the activity, and leaves + * Espresso with nothing resumed to look at. Its root picker then retries on a thirty-second + * backoff, and since {@code timeout_msec} is deliberately not set (it cost about three minutes a + * run), nothing ever stops it. The test does not fail; it never ends, and whatever limit is + * outermost - 45 minutes on CI - is what finally kills the job. + * + *

Per-class rules could not keep up, which is the actual reason this exists. The map + * fixture granted location up front and said in its own javadoc that a test which forgot the + * rule would hit the same wall. Then the nearby-tags work made the map ask for Bluetooth as it + * opens, and eight classes reach the map without that fixture - one of them with a + * {@code GrantPermissionRule} listing exactly the permissions that used to be enough. Nothing + * was wrong with any of them. They were written before the app asked for one more thing, and a + * rule is per-class, so there is no single place that a new permission can be added. + * + *

This is that place. Granting before the first test costs nothing and removes the whole + * class of failure. + * + *

Safe because nothing here tests a refusal. No test in this source set asserts that a + * permission is requested, rationalised or denied - checked before writing this - so there is no + * behaviour for a blanket grant to hide. If one is ever added, it needs to revoke what it is + * about in its own setup, and this comment is the warning that it must. + */ +public final class GrantWhatTheAppAsksForRunner extends AndroidJUnitRunner { + + @Override + public void onStart() { + final List needed = new ArrayList<>(List.of( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION)); + + // Read from the app rather than named again: which permissions Bluetooth needs depends + // on the API level, and a second copy is one that can be wrong without anybody noticing. + needed.addAll(List.of(BlePermissions.required())); + + final String packageName = this.getTargetContext().getPackageName(); + for (final String permission : needed) { + try { + this.getUiAutomation().grantRuntimePermission(packageName, permission); + } catch (final RuntimeException alreadyHeldOrNotGrantable) { + // Already granted, or not a runtime permission on this API level. Both are fine: + // the point is only that no dialog appears once the tests start. + } + } + + super.onStart(); + } +} From ab17d7f5d1a0f0b641ff043853c864a45cd15849 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sun, 30 Aug 2026 21:01:45 +0200 Subject: [PATCH 59/61] Keep every screen's buttons out of the navigation bar Reported from a Samsung phone: "any button we put at the bottom of the page (or random text) is barely to not clickable", with a screenshot of the keychain unlock screen's Unlock button behind the gesture pill. The theme draws under a transparent navigation bar, so a screen that does not pad for it puts its last control where the system takes the touches. **One helper instead of two halves.** There was a top-only one and a bottom-only one, and the top was applied to seven screens while the bottom went to one - nothing about writing the first suggests you owe the second. insetForSystemBars does both, and the top-only version is deleted rather than left there to be called again. AppleLoginActivity and ErrorReportActivity had neither: the second was invisible to a search-and-replace precisely because it called nothing. **Two screens needed more than the root padded, and the tests found both.** FetchFromICloud padded its scroll view, and its back and Unlock buttons sit outside it, anchored to the activity - so the padding moved the text and left the buttons exactly where the report showed them. It pads the root now. HistoryView's retry button is in a bottom sheet, positioned by its behaviour rather than by any parent's padding; padding the screen does not reach it and padding the sheet's own content did not move it either, measured both ways. It is left as it was: the sheet is dragged, so the button is reachable the way a scrolled row is, and doing it properly means the behaviour's peek height. The bar in the tests is invented, and that is the point: the managed device reports a systemBars bottom inset of zero, so asking the real device proves nothing on CI and a geometric assertion would pass on any layout at all. Dispatching a synthetic 240px bar asks whether the screen would keep its buttons out of one, which has the same answer everywhere. Covers every activity in the manifest except the map, which draws edge to edge by design. The iCloud screen is checked in FetchFromICloudFlowTest, which already knows how to give it a session - it closes itself without one. Confirmed to fail without the fix: with the login screen's call removed it reports "nothing on this screen reserved the 240px navigation bar". Co-Authored-By: Claude Opus 5 --- .../FetchFromICloudFlowTest.java | 22 ++ .../NothingSitsUnderTheNavigationBarTest.java | 148 +++++++++++++ .../ui/compat/TheNavigationBar.java | 202 ++++++++++++++++++ .../opentagviewer/AppleLoginActivity.java | 5 + .../opentagviewer/DeviceInfoActivity.java | 2 +- .../FetchFromICloudActivity.java | 6 +- .../opentagviewer/HistoryViewActivity.java | 2 +- .../opentagviewer/InformationActivity.java | 2 +- .../opentagviewer/MyDevicesListActivity.java | 2 +- .../opentagviewer/SettingsActivity.java | 8 +- .../ui/compat/WindowPaddingUtil.java | 46 +++- .../ui/error/ErrorReportActivity.java | 6 + 12 files changed, 433 insertions(+), 18 deletions(-) create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/NothingSitsUnderTheNavigationBarTest.java create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/TheNavigationBar.java diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java index 48c1f945..9be60d1c 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java @@ -38,6 +38,7 @@ import dev.wander.android.opentagviewer.anisette.AdiDeviceIdentity; import dev.wander.android.opentagviewer.db.AccountBeaconsForTests; import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.ui.compat.TheNavigationBar; import dev.wander.android.opentagviewer.python.icloud.FakeICloudService; import dev.wander.android.opentagviewer.python.icloud.ICloudService; @@ -123,6 +124,27 @@ private boolean isShown(final int id) { return shown[0]; } + /** + * The Unlock button is not under the navigation bar. + * + *

This is the screen from @parawanderer's report - a screenshot of the passcode step with + * Unlock behind the gesture pill, on a phone whose bar is tall. Every screen is checked for + * this in {@code NothingSitsUnderTheNavigationBarTest}; this one is checked here instead, + * because it closes itself in {@code onCreate} without a usable iCloud session and this class + * is what knows how to give it one. Rebuilding that setup there would be the copy that drifts. + * + *

The bar is invented rather than measured - see {@link TheNavigationBar} for why that is + * the only way to ask this on a device whose real inset is zero. + */ + @Test + public void theunlockButtonStaysClearOfTheNavigationBar() { + this.open(FakeICloudService.withTags()); + + this.chooseTheFirstDevice(); + + TheNavigationBar.doesNotCover(this.scenario, "FetchFromICloudActivity"); + } + /** The whole errand: choose a device, unlock, see what is on the account. */ @Test public void thewholeFlowReachesTheTagsOnTheAccount() { diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/NothingSitsUnderTheNavigationBarTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/NothingSitsUnderTheNavigationBarTest.java new file mode 100644 index 00000000..e9506e5b --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/NothingSitsUnderTheNavigationBarTest.java @@ -0,0 +1,148 @@ +package dev.wander.android.opentagviewer.ui.compat; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; + +import android.content.Context; +import android.content.Intent; +import android.widget.FrameLayout; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.List; + +import dev.wander.android.opentagviewer.AppleLoginActivity; +import dev.wander.android.opentagviewer.DeviceInfoActivity; +import dev.wander.android.opentagviewer.HistoryViewActivity; +import dev.wander.android.opentagviewer.InformationActivity; +import dev.wander.android.opentagviewer.MyDevicesListActivity; +import dev.wander.android.opentagviewer.SettingsActivity; +import dev.wander.android.opentagviewer.ui.error.ErrorReportActivity; +import dev.wander.android.opentagviewer.ui.maps.AMapWithTagsOnIt; + +/** + * Nothing a person has to press ends up underneath the navigation bar. + * + *

The bug, in @parawanderer's words: "any button we put at the bottom of the page (or + * random text) is barely to not clickable". The screenshot was the keychain unlock screen, its + * Unlock button behind the gesture pill. The theme draws under a transparent navigation bar, so + * a screen that does not pad for it puts its last control where the system takes the touches. + * + *

{@link TheNavigationBar} does the asking, and explains why the bar is invented rather than + * measured. This class brings the screens. + * + *

Every activity in the manifest except two. The map is out deliberately - it draws + * tiles edge to edge under the bar and pads only the card row above it, which + * {@code TagCardLayoutTest} covers. The iCloud flow is checked in + * {@code FetchFromICloudFlowTest} instead, because it closes itself without a usable session and + * that test already knows how to give it one; rebuilding that here would be the copy that drifts. + * + *

The list is still the weak point. A screen added later is not covered until somebody + * adds it - the same rot that let this happen. What protects the common case is that + * {@link WindowPaddingUtil#insetForSystemBars} does both bars at once, so a screen cannot handle + * the status bar and silently miss the navigation bar; the top-only helper it replaced is gone + * rather than left there to be called. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class NothingSitsUnderTheNavigationBarTest { + + /** Screens that open with nothing arranged. */ + private static final Class[] SCREENS_THAT_NEED_NOTHING = { + AppleLoginActivity.class, + MyDevicesListActivity.class, + InformationActivity.class, + SettingsActivity.class, + }; + + private final AMapWithTagsOnIt theMap = new AMapWithTagsOnIt(); + + @After + public void putItBack() { + this.theMap.putItBack(); + } + + /** + * The signed-out screens first, before anything stores a session. + * + *

{@code AMapWithTagsOnIt.seed} writes one, and {@code AppleLoginActivity} finishes itself + * and leaves for the map the moment one exists - so checking it after seeding gave "Activity + * has been destroyed already" rather than an answer. Two phases, in this order, for that + * reason alone. + */ + @Test + public void everyScreenKeepsItsControlsAboveTheNavigationBar() { + final Context context = getInstrumentation().getTargetContext(); + + final List beforeAnybodySignsIn = new ArrayList<>(); + for (final Class screen : SCREENS_THAT_NEED_NOTHING) { + beforeAnybodySignsIn.add(new Intent(context, screen)); + } + + // **The report page, which is what asking this question properly turned up.** It handled + // neither bar, and a search-and-replace over the screens that padded for the status bar + // could not find it precisely because it did none of it. Its Close and Share buttons are + // the last things on a scrolling page. + beforeAnybodySignsIn.add( + ErrorReportActivity.intentFor(context, "a made-up failure, for the test")); + + this.check(beforeAnybodySignsIn); + + // And the two that need a tag to look at, which comes with a session. + this.theMap.seed("A tag"); + final String beaconId = this.theMap.tagIds().get(0); + + this.check(List.of( + new Intent(context, DeviceInfoActivity.class).putExtra("beaconId", beaconId), + new Intent(context, HistoryViewActivity.class).putExtra("beaconId", beaconId))); + } + + private void check(final List screens) { + for (final Intent screen : screens) { + final String name = screen.getComponent().getShortClassName(); + + try (ActivityScenario scenario = ActivityScenario.launch(screen)) { + TheNavigationBar.doesNotCover(scenario, name); + } + } + } + + /** + * And applying them twice does not double the gap. + * + *

Insets are delivered more than once - a rotation, the keyboard opening, somebody + * switching to three-button navigation - so a helper that added the inset to whatever padding + * it found would grow the gap on every delivery. It reads its own starting padding once, and + * this is what says so. + */ + @Test + public void repeatedInsetsDoNotAccumulate() { + final int[] afterOne = new int[1]; + final int[] afterThree = new int[1]; + + getInstrumentation().runOnMainSync(() -> { + final FrameLayout view = new FrameLayout(getInstrumentation().getTargetContext()); + view.setPadding(0, 0, 0, 17); + WindowPaddingUtil.insetForSystemBars(view); + + TheNavigationBar.putABarUnder(view); + afterOne[0] = view.getPaddingBottom(); + + TheNavigationBar.putABarUnder(view); + TheNavigationBar.putABarUnder(view); + afterThree[0] = view.getPaddingBottom(); + }); + + assertEquals("the view's own 17px should be kept, with the bar's height added to it", + 17 + TheNavigationBar.A_TALL_ONE, afterOne[0]); + assertEquals("three deliveries of the same insets must leave the same padding as one", + afterOne[0], afterThree[0]); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/TheNavigationBar.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/TheNavigationBar.java new file mode 100644 index 00000000..42b1b7e5 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/TheNavigationBar.java @@ -0,0 +1,202 @@ +package dev.wander.android.opentagviewer.ui.compat; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertNull; + +import android.app.Activity; +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; + +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.test.core.app.ActivityScenario; + +import java.util.ArrayList; +import java.util.List; + +/** + * Asks a screen whether it would keep its controls out of a navigation bar. + * + *

The bar is invented, and that is the point. The managed device this suite runs on + * reports a {@code systemBars} bottom inset of zero - measured, while chasing a different + * bug - so asking the real device where its navigation bar is proves nothing on CI, and a + * geometric assertion would pass on any layout at all. Dispatching a synthetic inset asks the + * question that actually matters: if there were a bar this tall, would this screen keep + * its buttons out of it? Same answer on every device. + * + *

Here rather than in one test class because the screens live in different setups. Most + * open with nothing arranged; the iCloud flow needs a session its own flow test knows how to + * build. Copying the check into that test would be the version that drifts, so the check is + * shared and each test brings its own screen. + */ +public final class TheNavigationBar { + + /** Taller than any real navigation bar, so a screen that ignores it cannot pass by luck. */ + public static final int A_TALL_ONE = 240; + + private static final int A_STATUS_BAR = 90; + + private TheNavigationBar() { + } + + /** + * Pretend this screen has a tall navigation bar, and fail if anything a person must press + * would end up underneath it. + * + *

Takes the scenario, not the activity, and that is not a style choice. This has to + * hop to the main thread to touch views and back off it to wait for a layout pass, and + * {@code runOnMainSync} throws "This method can not be called from the main application + * thread" if it is already there. Handing it an {@code Activity} invited exactly that: the + * obvious way to get one is inside {@code onActivity}, which is on the main thread. + */ + public static void doesNotCover(final ActivityScenario scenario, final String screen) { + scenario.onActivity(activity -> putABarUnder(activity.findViewById(android.R.id.content))); + getInstrumentation().waitForIdleSync(); + + final String[] problem = new String[1]; + scenario.onActivity(activity -> { + problem[0] = whatSitsUnderIt(activity, screen); + if (problem[0] == null) { + problem[0] = didNothingReserveIt(activity, screen); + } + }); + + assertNull(problem[0], problem[0]); + } + + public static void putABarUnder(final View view) { + ViewCompat.dispatchApplyWindowInsets(view, new WindowInsetsCompat.Builder() + .setInsets(WindowInsetsCompat.Type.systemBars(), + Insets.of(0, A_STATUS_BAR, 0, A_TALL_ONE)) + .build()); + } + + /** @return a description of the first control found inside the bar, or null if all is well. */ + private static String whatSitsUnderIt(final Activity activity, final String screen) { + final View root = activity.findViewById(android.R.id.content); + final int barStartsAt = root.getHeight() - A_TALL_ONE; + + for (final View control : clickableThingsIn(root)) { + if (control == root) { + continue; + } + + final Rect bounds = new Rect(0, 0, control.getWidth(), control.getHeight()); + ((ViewGroup) root).offsetDescendantRectToMyCoords(control, bounds); + + // **A full-height container that happens to be clickable is not a control.** + // InformationActivity's root carries android:clickable and spans the screen, so it + // "ends below the bar" by definition - and it is the very view whose padding keeps + // the real controls out of the bar. Judging it as a button failed a correct screen. + if (bounds.height() > root.getHeight() * 0.7) { + continue; + } + + // **Something inside a scrolling list is not stuck there.** Settings' last switches + // sit under the bar at rest and a flick brings them up, which is ordinary and fine. + // The complaint is about controls that cannot be moved - a button anchored at the + // bottom, like the keychain Unlock button in the report. + if (canBeScrolledClear(control, root)) { + continue; + } + + if (bounds.bottom > barStartsAt) { + return screen + ": a control ends at " + bounds.bottom + " but the navigation bar" + + " starts at " + barStartsAt + " (" + describe(activity, control) + ")" + + " - it would be under the bar and hard or impossible to press"; + } + } + return null; + } + + /** + * And the screen has to reserve the bar's height somewhere. + * + *

The check above only looks at controls that exist and are anchored, so a screen whose + * bottom happens to be empty passes it while handling no insets at all - and then puts a + * button under the bar the moment somebody adds one. + * + *

Loose on purpose: which view holds the padding differs by screen - the root on most, the + * scroll container on the iCloud flow - and pinning that per screen would be one more + * per-screen thing to keep in step. + */ + private static String didNothingReserveIt(final Activity activity, final String screen) { + for (final View view : everythingIn(activity.findViewById(android.R.id.content))) { + if (view.getPaddingBottom() >= A_TALL_ONE) { + return null; + } + } + return screen + ": nothing on this screen reserved the " + A_TALL_ONE + "px navigation" + + " bar, so it is not handling window insets at all - anything put at the bottom" + + " of it will end up under the bar"; + } + + private static boolean canBeScrolledClear(final View control, final View root) { + for (ViewGroup parent = (ViewGroup) control.getParent(); + parent != null && parent != root.getParent(); + parent = parent.getParent() instanceof ViewGroup + ? (ViewGroup) parent.getParent() : null) { + + if (parent instanceof android.widget.ScrollView + || parent instanceof android.widget.HorizontalScrollView + || parent instanceof androidx.core.widget.NestedScrollView + || parent instanceof androidx.recyclerview.widget.RecyclerView + || parent instanceof android.widget.ListView) { + return true; + } + + // **A bottom sheet is dragged, which is the same kind of movable.** The history + // sheet's retry button measures inside the bar at rest, and a drag upwards brings it + // out - so it is not the stuck button this is about. + // + // Known gap, deliberately: a sheet is positioned by its BottomSheetBehavior rather + // than by its parent's padding, so the screen-level inset does not reach into it and + // padding the sheet's own content did not move it either - measured, both ways. Doing + // it properly means the behaviour's peek height, which is a bigger change than the + // one this test was written for. + if (parent.getLayoutParams() + instanceof androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams + && ((androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams) + parent.getLayoutParams()).getBehavior() != null) { + return true; + } + } + return false; + } + + private static List clickableThingsIn(final View view) { + final List found = new ArrayList<>(); + if (view.getVisibility() == View.VISIBLE && view.isClickable() && view.getWidth() > 0) { + found.add(view); + } + if (view instanceof ViewGroup) { + final ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + found.addAll(clickableThingsIn(group.getChildAt(i))); + } + } + return found; + } + + private static List everythingIn(final View view) { + final List found = new ArrayList<>(); + found.add(view); + if (view instanceof ViewGroup) { + final ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + found.addAll(everythingIn(group.getChildAt(i))); + } + } + return found; + } + + private static String describe(final Activity activity, final View view) { + try { + return activity.getResources().getResourceEntryName(view.getId()); + } catch (final Exception noName) { + return view.getClass().getSimpleName(); + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java index 5a47b334..c6a7234b 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java @@ -32,6 +32,8 @@ import androidx.appcompat.app.AppCompatDelegate; import androidx.core.os.LocaleListCompat; import androidx.databinding.DataBindingUtil; + +import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; import androidx.lifecycle.ViewModelProvider; import com.chaquo.python.PyObject; @@ -231,6 +233,9 @@ public void handleOnBackPressed() { this.twoFactorEntryManager = new Apple2FACodeInputManager(this, this::on2FAAuthCodeFilled); this.binding = DataBindingUtil.setContentView(this, R.layout.activity_apple_login); + // This screen had neither inset applied - so its buttons sat under the navigation bar + // and its heading under the status bar, on the very first screen anybody sees. + WindowPaddingUtil.insetForSystemBars(this.binding.getRoot()); if (this.getSupportActionBar() != null) { this.getSupportActionBar().hide(); 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 b955b717..a8a2bff4 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java @@ -238,7 +238,7 @@ protected void onCreate(Bundle savedInstanceState) { : this.beaconRepo.getImportById(importId).blockingFirst().orElse(null); binding = DataBindingUtil.setContentView(this, R.layout.activity_device_info); - WindowPaddingUtil.insertUITopPadding(binding.getRoot()); + WindowPaddingUtil.insetForSystemBars(binding.getRoot()); binding.setHandleClickBack(this::handleEndActivity); binding.setHandleClickMenu(this::handleClickMenu); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java index bf192976..55c895f4 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java @@ -175,7 +175,11 @@ protected void onCreate(final Bundle savedInstanceState) { this.membershipRepo = new KeychainMembershipRepository( UserAuthDataStore.getInstance(this.getApplicationContext()), new AppCryptographyUtil()); - WindowPaddingUtil.insertUITopPadding(this.findViewById(R.id.icloud_scroll)); + // **The root, not the scroll area.** The buttons on this screen - back, and the primary + // one that says Unlock - sit *outside* icloud_scroll, anchored to the bottom of the + // activity, so padding the scroll view moved the text and left them exactly where they + // were: under the navigation bar. That is the screenshot in the bug report. + WindowPaddingUtil.insetForSystemBars(this.findViewById(R.id.icloud_root)); if (this.getSupportActionBar() != null) { this.getSupportActionBar().hide(); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java index d8f6ecd8..62c027e4 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java @@ -180,7 +180,7 @@ protected void onCreate(Bundle savedInstanceState) { .blockingFirst(); ActivityHistoryViewBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_history_view); - WindowPaddingUtil.insertUITopPadding(binding.getRoot()); + WindowPaddingUtil.insetForSystemBars(binding.getRoot()); binding.setHandleClickBack(this::finish); binding.setPageTitle(this.getCurrentBeaconName()); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java index 3b6e0b07..4e2e2886 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java @@ -44,7 +44,7 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ActivityInformationBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_information); - WindowPaddingUtil.insertUITopPadding(binding.getRoot()); + WindowPaddingUtil.insetForSystemBars(binding.getRoot()); binding.setHandleClickBack(this::finish); if (this.getSupportActionBar() != null) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java index bb21f65a..74236046 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java @@ -214,7 +214,7 @@ protected void onCreate(Bundle savedInstanceState) { } this.binding = DataBindingUtil.setContentView(this, R.layout.activity_my_devices_list); - WindowPaddingUtil.insertUITopPadding(this.binding.getRoot()); + WindowPaddingUtil.insetForSystemBars(this.binding.getRoot()); this.binding.setHandleClickBack(this::handleEndActivity); if (this.getSupportActionBar() != null) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java index 76840a67..a7aaae7e 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java @@ -182,9 +182,11 @@ protected void onCreate(Bundle savedInstanceState) { this.themeChoices.add(this.getString(R.string.dark_theme)); this.binding = DataBindingUtil.setContentView(this, R.layout.activity_settings); - WindowPaddingUtil.insertUITopPadding(binding.getRoot()); - // The last row is the debug switch, and the navigation bar was sitting on top of it. - WindowPaddingUtil.insertUIBottomPadding(this.findViewById(R.id.settings_scroll_area)); + WindowPaddingUtil.insetForSystemBars(binding.getRoot()); + // The bottom inset used to be applied to the scroll area here as well - the debug switch + // is the last row and the navigation bar sat on top of it. insetForSystemBars above now + // does that for the whole screen, as it does for every other one, so a second call would + // reserve the bar's height twice. this.binding.setHandleClickBack(this::handleEndActivity); this.binding.setOnClickFetchFromAccount(this::onClickFetchFromAccount); this.binding.setOnClickUnlinkAccount(this::onClickUnlinkAccount); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java index 18ecffe9..e0ca3536 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java @@ -11,20 +11,46 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class WindowPaddingUtil { + /** - * In UIs like Samsung Galaxy S25 Ultra, the top padding under the top list of icons in the UI - * (the notifications, time, battery, ...) is absent, which results in a top bar that is too small + * Keep a screen's content clear of both system bars. + * + *

The bottom is the one that gets forgotten, and it is the one people notice. The + * theme draws under a transparent navigation bar, so anything at the bottom of a screen ends + * up beneath it - and unlike a clipped heading, a button underneath the bar is not merely + * ugly. It is hard to press, or impossible: the bar takes the touch. Reported on a Samsung + * phone as "any button we put at the bottom of the page is barely to not clickable", with a + * screenshot of the keychain unlock screen's Unlock button sitting behind the gesture pill. + * + *

Both bars in one call, because two calls is what let this happen. There used to + * be a top-only helper; it was applied to seven screens and the bottom inset to one, and + * nothing about writing the first suggests you owe the second. Anything that pads for the + * status bar has the same problem at the other end of the screen, so the top-only version + * is gone rather than left available to be called again. * - * @param rootView The view that holds all of the UI for a given activity. + *

The view's own padding is kept and the insets are added to it, so a layout that already + * asks for breathing room does not lose it - and the values are read once, here, rather than + * inside the listener. Insets arrive more than once (a rotation, a keyboard, switching to + * three-button navigation), and adding to the current padding each time would grow the gap + * on every delivery. + * + *

Not for a screen that deliberately draws edge to edge. The map is the example: it wants + * tiles under the bar and pads only the card row above it, with + * {@link #insertUIBottomPadding}. */ - public static void insertUITopPadding(View rootView) { - ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> { - Insets statusBarInsets = insets.getInsets(WindowInsetsCompat.Type.statusBars()); + public static void insetForSystemBars(final View view) { + final int ownLeft = view.getPaddingLeft(); + final int ownTop = view.getPaddingTop(); + final int ownRight = view.getPaddingRight(); + final int ownBottom = view.getPaddingBottom(); + + ViewCompat.setOnApplyWindowInsetsListener(view, (v, insets) -> { + final Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); v.setPadding( - 0, - statusBarInsets.top, - 0, - 0 + ownLeft + bars.left, + ownTop + bars.top, + ownRight + bars.right, + ownBottom + bars.bottom ); return insets; }); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/error/ErrorReportActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/error/ErrorReportActivity.java index 4f3c9401..dc0d4e8c 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/error/ErrorReportActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/error/ErrorReportActivity.java @@ -26,6 +26,7 @@ import java.io.Writer; import java.nio.charset.StandardCharsets; +import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; import dev.wander.android.opentagviewer.BuildConfig; import dev.wander.android.opentagviewer.R; import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; @@ -108,6 +109,11 @@ public static String describe(final Throwable error) { protected void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); this.setContentView(R.layout.activity_error_report); + // This screen handled neither bar - it was missed when the others were fixed, because it + // never called the old top-only helper either, so there was nothing to find and replace. + // Its Close and Share buttons are the last things on a scrolling page, which is exactly + // where the navigation bar lands. + WindowPaddingUtil.insetForSystemBars(this.findViewById(R.id.error_report_root)); if (this.getSupportActionBar() != null) { this.getSupportActionBar().hide(); From 2e944ac6b0d64ed16060497723b7e4976b340ea0 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sun, 30 Aug 2026 21:20:37 +0200 Subject: [PATCH 60/61] Stop the PR watcher calling an empty check list a pass `[.statusCheckRollup[].status] | all(. == "COMPLETED")` is true for an empty list, so the loop announced "all checks finished" one line after a push, for a PR with zero checks. That reads exactly like a green build, which is the failure this skill exists to prevent - it says in bold that silence is not success, and the loop it recommends had the same hole. Two shapes produce an empty rollup and neither is an outcome: a commit whose runs GitHub has not created yet, and a fork PR waiting on "Approve and run", which sits there indefinitely and reports "no checks reported on the branch". The second is now documented with the command to approve it, because it looks like a broken CI config and is not. Co-Authored-By: Claude Opus 5 --- .claude/skills/watch-pr/SKILL.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.claude/skills/watch-pr/SKILL.md b/.claude/skills/watch-pr/SKILL.md index 81603aaa..5d35bd5f 100644 --- a/.claude/skills/watch-pr/SKILL.md +++ b/.claude/skills/watch-pr/SKILL.md @@ -88,7 +88,7 @@ while true; do break fi if [ "$(gh pr view "$PR" --json statusCheckRollup --jq \ - '[.statusCheckRollup[].status] | all(. == "COMPLETED")' 2>/dev/null)" = "true" ]; then + '[.statusCheckRollup[].status] | length > 0 and all(. == "COMPLETED")' 2>/dev/null)" = "true" ]; then echo "PR #$PR: all $total checks finished" break fi @@ -112,6 +112,22 @@ Why it is shaped this way: through a red build, and silence reads as "still running". - **`2>/dev/null` on the `gh` calls, but no `|| continue`.** A transient API failure yields an empty result and the loop tries again; it must not be able to exit quietly. +- **`length > 0 and` before the `all`, because `all` on an empty list is `true`.** Without it the + loop declares victory the instant the rollup is empty — which is exactly what a freshly pushed + commit looks like before GitHub has created its runs, and what a fork PR waiting on + *"Approve and run"* looks like indefinitely. It printed `all checks finished` for a PR with + **zero** checks, one line after a push, and that reads identically to a green build. + +**A fork PR can sit at `action_required` forever, and that is not a failure state.** GitHub gates +workflow runs on PRs from forks, so `gh run list` shows `completed / action_required` and +`gh pr checks` says *"no checks reported"*. Nothing is wrong and nothing will happen until a +maintainer approves: + +```bash +gh run list --limit 5 --json databaseId,headSha,conclusion \ + --jq '.[] | select(.conclusion=="action_required") | .databaseId' +gh api -X POST repos///actions/runs//approve +``` ## When it lands From 77aee3e92d317faf62d39db13ed7d271f86f8369 Mon Sep 17 00:00:00 2001 From: "Shane B." Date: Thu, 3 Sep 2026 19:24:51 +0200 Subject: [PATCH 61/61] Read the alignment a fetch left behind, not the one the export shipped Two things, both about the same value being read from the wrong place. **The long-fetch banner shows for tags that are perfectly aligned.** 60c1aee added SlowFirstFetch so the banner only appears when the key search is genuinely wide, and it asks KeyAlignmentPlist, which reads lastIndexObservationDate out of the export's KeyAlignmentRecord. That column is written by refreshFromImport and refreshFromAccount and by nothing else. It is frozen at import. The alignment that actually decides the search width lives in accessory_json: FindMy.py serialises alignment_date and alignment_index, and updateAccessoryJson writes the whole blob back after every fetch. So anybody whose export is more than seven days old sees "Locating your tags (x of y)" on every single refresh, however recently their tags updated. Reported from a phone on this branch with tags that updated today. KeyAlignmentPlist's own docstring says what it is for - "the one thing known about a tag before anybody has ever scanned for it" - and ScanOrder honours that, with a comment saying the record is "only ever consulted for a tag with no scan history". SlowFirstFetch was the one caller that did not. AccessoryAlignment reads both fields out of the accessory state, and SlowFirstFetch.laterOf takes whichever of the two timestamps is newer: the live one normally, the export's record before the first fetch, and a re-import's record when it is fresher than a stale blob. Jackson rather than org.json, because org.json lives in android.jar and the JVM test runtime stubs it - a test would read zero from a document saying otherwise and pass. Rule 13. **And the tag page now shows the alignment, in the debug panel.** Index and date, or a line saying none is stored and the next fetch will search from the pairing date. That is the first thing worth quoting in a report about a fetch that takes minutes or comes back empty, and until now there was no way to see it short of reading the database. Nine JVM tests. Three strings in ten locales via add_strings.py. Not verified locally: no Android SDK on this machine, so the build and the suites are CI's to run. Co-Authored-By: Claude Opus 5 (1M context) --- .../opentagviewer/DeviceInfoActivity.java | 27 ++++- .../db/repo/BeaconRepository.java | 10 +- .../util/parse/AccessoryAlignment.java | 102 ++++++++++++++++++ .../opentagviewer/util/rx/SlowFirstFetch.java | 33 ++++++ .../main/res/layout/activity_device_info.xml | 15 +++ app/src/main/res/values-de/strings.xml | 3 + app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values-fr/strings.xml | 3 + app/src/main/res/values-ja/strings.xml | 3 + app/src/main/res/values-ko/strings.xml | 3 + app/src/main/res/values-nl/strings.xml | 3 + app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values-zh-rTW/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../util/parse/AccessoryAlignmentTest.java | 85 +++++++++++++++ .../util/rx/SlowFirstFetchTest.java | 52 +++++++++ 17 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/parse/AccessoryAlignment.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/util/parse/AccessoryAlignmentTest.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 a8a2bff4..af1545c5 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java @@ -83,6 +83,7 @@ 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.AccessoryAlignment; import dev.wander.android.opentagviewer.util.parse.BatteryLevelDescription; import dev.wander.android.opentagviewer.util.parse.BeaconDataParser; import dev.wander.android.opentagviewer.util.parse.LocationReportFields; @@ -364,7 +365,8 @@ protected void onCreate(Bundle savedInstanceState) { R.id.settings_debug_naming_record_pairing_date, R.id.settings_debug_naming_record_product_id, R.id.settings_debug_naming_record_system_version, - R.id.settings_debug_naming_record_vendor_id + R.id.settings_debug_naming_record_vendor_id, + R.id.settings_debug_key_alignment ); ClipboardManager clipboard = (ClipboardManager) @@ -1288,6 +1290,29 @@ private void describeHowItIsBeingLookedFor(final SimpleDateFormat timestamps) { : timestamps.format(new Date(newest))); this.binding.setBackoffState(this.describeBackoff(timestamps)); + this.binding.setKeyAlignment(this.describeKeyAlignment(timestamps)); + } + + /** + * Where the next key search starts, and how far the last one got. + * + *

Read from the accessory state, not from the export's record. The record is written + * once at import; this advances on every fetch, which is what makes it worth showing. A tag + * whose row says "None stored" is one whose next fetch searches from the pairing date - tens + * of thousands of keys for an old tag - and that is the single most useful thing to know when + * somebody reports a fetch that takes minutes or comes back empty. + */ + private String describeKeyAlignment(final SimpleDateFormat timestamps) { + final String accessoryJson = this.beaconInformation.getOwnedBeaconAccessoryJson(); + final Integer index = AccessoryAlignment.alignedIndex(accessoryJson); + final Long alignedAt = AccessoryAlignment.alignedAtMillis(accessoryJson); + + if (index == null || alignedAt == null) { + return this.getString(R.string.debug_key_alignment_none); + } + + return this.getString( + R.string.debug_key_alignment_value, index, timestamps.format(new Date(alignedAt))); } private String describeBackoff(final SimpleDateFormat timestamps) { 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 5eca2f8f..3b9e69ea 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 @@ -36,6 +36,7 @@ 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.AccessoryAlignment; import dev.wander.android.opentagviewer.util.parse.KeyAlignmentPlist; import dev.wander.android.opentagviewer.util.rx.ScanOrder; import dev.wander.android.opentagviewer.util.rx.SlowFirstFetch; @@ -509,8 +510,13 @@ public Observable aFetchOfTheseWouldBeSlow(final List for (final AccessoryRequest request : requests) { final OwnedBeacon row = dao.getById(request.getBeaconId()); - alignedAt.add(row == null - ? null : KeyAlignmentPlist.observedAtMillis(row.alignmentPlist)); + // **Both, and the later one wins.** The export's record is frozen at import; + // the accessory state carries the alignment the last fetch actually reached. + // Reading only the record showed the banner on every refresh for anybody whose + // export was more than a week old, however recently their tags had updated. + alignedAt.add(row == null ? null : SlowFirstFetch.laterOf( + AccessoryAlignment.alignedAtMillis(row.accessoryJson), + KeyAlignmentPlist.observedAtMillis(row.alignmentPlist))); } return SlowFirstFetch.isLikely(alignedAt, System.currentTimeMillis()); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AccessoryAlignment.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AccessoryAlignment.java new file mode 100644 index 00000000..e8c0039b --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AccessoryAlignment.java @@ -0,0 +1,102 @@ +package dev.wander.android.opentagviewer.util.parse; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.time.Instant; +import java.time.OffsetDateTime; + +/** + * Where a tag's rolling key search will actually start, read out of its live accessory state. + * + *

This is the value {@link KeyAlignmentPlist} is a stand-in for, and it moves. The plist + * is the {@code KeyAlignmentRecord} the export was made with: written once, at import, and never + * touched again. FindMy.py's serialised accessory is the state the app hands back to Python on + * every fetch and stores again afterwards - see {@code OwnedBeaconDao#updateAccessoryJson} - so + * its {@code alignment_date} is where the next search begins. + * + *

The two disagree from the first successful fetch onwards, and the disagreement grows. A tag + * exported three weeks ago and fetched hourly ever since has a three-week-old plist and an + * alignment date from this morning. + * + *

Jackson rather than {@code org.json}. {@code org.json} ships inside {@code android.jar}, + * where the JVM test runtime stubs it and every getter answers a default - so a test would read + * zero from a document that says otherwise and pass. Jackson is a real dependency on both, which + * is what lets the whole of this be a JVM test. See AGENTS.md rule 13. + */ +public final class AccessoryAlignment { + + private static final String TAG = AccessoryAlignment.class.getSimpleName(); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private AccessoryAlignment() { + } + + /** + * When this accessory's keys were last aligned, from the state the last fetch left behind. + * + * @param accessoryJson {@code OwnedBeacon.accessoryJson}, or null if the row has none. + * @return milliseconds since the epoch, or null if there is no usable date in it. Null is + * ordinary: a tag imported and never fetched has no alignment date yet, and rows + * predating the FindMy 0.9.x upgrade have no accessory JSON at all. + */ + @Nullable + public static Long alignedAtMillis(@Nullable final String accessoryJson) { + final JsonNode value = read(accessoryJson, "alignment_date"); + if (value == null || !value.isTextual()) { + return null; + } + + try { + // FindMy.py writes datetime.isoformat(), which carries an offset. Instant.parse + // wants a 'Z', so this goes through OffsetDateTime and accepts either. + return OffsetDateTime.parse(value.asText().trim()).toInstant().toEpochMilli(); + } catch (final Exception notADate) { + try { + return Instant.parse(value.asText().trim()).toEpochMilli(); + } catch (final Exception stillNotADate) { + Log.w(TAG, "An accessory carried an alignment_date this cannot read", stillNotADate); + return null; + } + } + } + + /** + * The rolling key index the last fetch reached, for the debug panel. + * + *

Shown rather than used: it is the number a bug report about a slow or empty fetch wants + * quoted, because it says how far the search had got and therefore how far the next one has + * to go. Keys step every fifteen minutes, so the index is roughly ninety-six per day since + * pairing. + * + * @return the index, or null if the accessory has never been aligned. + */ + @Nullable + public static Integer alignedIndex(@Nullable final String accessoryJson) { + final JsonNode value = read(accessoryJson, "alignment_index"); + return value == null || !value.isNumber() ? null : value.asInt(); + } + + @Nullable + private static JsonNode read(@Nullable final String accessoryJson, final String field) { + if (accessoryJson == null || accessoryJson.isBlank()) { + return null; + } + + try { + final JsonNode node = MAPPER.readTree(accessoryJson).get(field); + return node == null || node.isNull() ? null : node; + } catch (final Exception unreadable) { + // Deliberately broad, and for the same reason KeyAlignmentPlist is: this runs to + // decide whether to show a banner and to fill a debug row. Neither is worth failing + // a fetch over, and "unknown" is a perfectly good answer. + Log.w(TAG, "Could not read " + field + " out of an accessory", unreadable); + return null; + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetch.java b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetch.java index 997f942e..63069f81 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetch.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetch.java @@ -36,6 +36,10 @@ * wait for something that turns out to be quick costs a moment's attention, and being told * nothing during three minutes of apparent hang is what this whole mechanism exists to prevent. * + *

Which timestamp is the live one matters, and originally the wrong one was read. The + * record in the export is written once at import; the accessory state Python returns after every + * fetch carries an {@code alignment_date} that moves. See {@link #laterOf}. + * *

Pure and on the JVM, per AGENTS.md rule 13 - it takes timestamps and returns a boolean. */ public final class SlowFirstFetch { @@ -61,6 +65,35 @@ private SlowFirstFetch() { * enough: the batch is fetched one accessory at a time, so a single unaligned tag * holds up everything behind it. */ + /** + * The later of what the export recorded and what the last fetch left behind. + * + *

The record in the export stops being the answer the moment a fetch succeeds. It is + * written once at import and never again; the accessory state Python hands back carries an + * {@code alignment_date} that advances every time. Reading only the record meant a tag + * exported a month ago and fetched hourly ever since still looked like a month-wide search, + * so the banner went up on every refresh - which is the noise this class was written to stop. + * + *

{@code ScanOrder} already had this right, and its comment says so: the record there is + * "only ever consulted for a tag with no scan history". This is that rule, for this caller. + * + *

The later of the two rather than simply preferring the live value, because a re-import + * can bring a newer record than a stale accessory blob, and neither is wrong to trust. + * + * @param alignedAt {@code alignment_date} from the accessory state, or null. + * @param observedAt {@code lastIndexObservationDate} from the export's record, or null. + * @return the later of the two, or null when neither is known - the slowest case there is. + */ + public static Long laterOf(final Long alignedAt, final Long observedAt) { + if (alignedAt == null) { + return observedAt; + } + if (observedAt == null) { + return alignedAt; + } + return Math.max(alignedAt, observedAt); + } + public static boolean isLikely(final Collection alignmentObservedAt, final long now) { if (alignmentObservedAt == null) { // Nothing known about the batch. Treated as slow, because the alternative is diff --git a/app/src/main/res/layout/activity_device_info.xml b/app/src/main/res/layout/activity_device_info.xml index 19279809..b1eb0a2b 100644 --- a/app/src/main/res/layout/activity_device_info.xml +++ b/app/src/main/res/layout/activity_device_info.xml @@ -129,6 +129,13 @@ name="bleStatusByte" type="String" /> + + + + + Die gespeicherte Verbindung zum Apple-Konto ließ sich nicht entschlüsseln, obwohl ihr Schlüssel noch vorhanden ist Die Verbindung zu deinem Apple-Konto liegt auf diesem Gerät, der Schlüssel dazu ist noch da, und sie lässt sich trotzdem nicht mehr öffnen – das sollte nicht möglich sein.\n\nDeshalb lohnt sich eine Meldung, statt es einfach neu einzurichten. Deine Tags und ihr Standortverlauf sind nicht betroffen, und aus deinem Apple-Konto wurde nichts entfernt. + Schlüssel-Ausrichtung + Index %1$d, ausgerichtet %2$s + Keine gespeichert — der nächste Abruf sucht ab dem Kopplungsdatum \ 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 92c09e16..29e2b39c 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -360,4 +360,7 @@ You can set this up now, or any time later from Settings. Your tags and their location history are not affected, and nothing was removed from your Apple account. The saved Apple account connection could not be decrypted, although its key is still present The connection to your Apple account is stored on this device, the key that unlocks it is still here, and it no longer opens — which should not be possible.\n\nThat makes it worth reporting rather than just redoing. Your tags and their location history are not affected, and nothing was removed from your Apple account. + Key alignment + Index %1$d, aligned %2$s + None stored — the next fetch searches from the pairing date \ 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 a12c6a2a..f04093b7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -360,4 +360,7 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.< Vos tags et leur historique de position ne sont pas touchés, et rien n’a été supprimé de votre compte Apple. La connexion au compte Apple enregistrée n’a pas pu être déchiffrée, bien que sa clé soit toujours présente La connexion à votre compte Apple est enregistrée sur cet appareil, la clé qui l’ouvre est toujours là, et elle ne s’ouvre plus : cela ne devrait pas être possible.\n\nCela vaut donc la peine d’être signalé plutôt que simplement refait. Vos tags et leur historique de position ne sont pas touchés, et rien n’a été supprimé de votre compte Apple. + Alignement des clés + Index %1$d, aligné %2$s + Aucun enregistré — la prochaine récupération part de la date d\'association \ 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 b8e1325b..7d2dcd23 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -360,4 +360,7 @@ タグとその位置履歴には影響がなく、Apple アカウントからは何も削除されていません。 保存された Apple アカウント接続を復号できませんでした。鍵は残っています Apple アカウントへの接続はこの端末に保存されていて、それを開く鍵も残っているのに、開けなくなりました。本来ありえないことです。\n\nそのため、設定し直すだけでなく報告する価値があります。タグとその位置履歴には影響がなく、Apple アカウントからは何も削除されていません。 + キーの同期位置 + インデックス %1$d、同期日時 %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 9e8545af..e61477dc 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -360,4 +360,7 @@ 태그와 위치 기록에는 영향이 없으며, Apple 계정에서 삭제된 것도 없습니다. 저장된 Apple 계정 연결을 복호화하지 못했습니다. 키는 그대로 남아 있습니다 Apple 계정 연결은 이 기기에 저장되어 있고 이를 여는 키도 그대로 있는데 더 이상 열리지 않습니다. 원래는 있을 수 없는 일입니다.\n\n그래서 그냥 다시 설정하기보다 신고할 가치가 있습니다. 태그와 위치 기록에는 영향이 없으며, Apple 계정에서 삭제된 것도 없습니다. + 키 정렬 + 인덱스 %1$d, 정렬 시각 %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 59c2b938..099fe0dc 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -360,4 +360,7 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen. Je tags en hun locatiegeschiedenis blijven ongemoeid, en er is niets uit je Apple-account verwijderd. De opgeslagen verbinding met het Apple-account kon niet worden ontsleuteld, terwijl de sleutel er nog wel is De verbinding met je Apple-account staat op dit apparaat, de sleutel die hem opent is er nog, en toch gaat hij niet meer open — dat hoort niet te kunnen.\n\nDaarom is dit het melden waard in plaats van het gewoon opnieuw te doen. Je tags en hun locatiegeschiedenis blijven ongemoeid, en er is niets uit je Apple-account verwijderd. + Sleuteluitlijning + Index %1$d, uitgelijnd %2$s + Niets opgeslagen — de volgende ophaalactie zoekt vanaf de koppeldatum \ 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 2cddefb4..a97324a8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -360,4 +360,7 @@ Ваши метки и история их местоположений не затронуты, и из учётной записи Apple ничего не удалено. Сохранённое подключение к учётной записи Apple не удалось расшифровать, хотя его ключ на месте Подключение к вашей учётной записи Apple хранится на этом устройстве, ключ к нему на месте, и оно всё равно не открывается — так быть не должно.\n\nПоэтому об этом стоит сообщить, а не просто настроить заново. Ваши метки и история их местоположений не затронуты, и из учётной записи Apple ничего не удалено. + Выравнивание ключей + Индекс %1$d, выровнено %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 e6c7259b..fbbc49f7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -360,4 +360,7 @@ 你的标签及其位置历史不受影响,Apple 账户中也没有任何内容被移除。 已保存的 Apple 账户连接无法解密,但其密钥仍然存在 与你的 Apple 账户的连接就保存在本设备上,解开它的密钥也还在,却打不开了——这本不该发生。\n\n因此这值得报告,而不只是重做一次。你的标签及其位置历史不受影响,Apple 账户中也没有任何内容被移除。 + 密钥对齐 + 索引 %1$d,对齐于 %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 23195d49..102c8906 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -360,4 +360,7 @@ 你的標籤與其位置紀錄不受影響,Apple 帳戶中也沒有任何內容被移除。 已儲存的 Apple 帳戶連線無法解密,但其金鑰仍然存在 與你的 Apple 帳戶的連線就儲存在本裝置上,解開它的金鑰也還在,卻打不開了——這本不該發生。\n\n因此這值得回報,而不只是重做一次。你的標籤與其位置紀錄不受影響,Apple 帳戶中也沒有任何內容被移除。 + 金鑰對齊 + 索引 %1$d,對齊於 %2$s + 未儲存 — 下次擷取將從配對日期開始搜尋 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 73ff7a65..3ac7516c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -393,4 +393,7 @@ You can set this up now, or any time later from Settings. Your tags and their location history are not affected, and nothing was removed from your Apple account. The saved Apple account connection could not be decrypted, although its key is still present The connection to your Apple account is stored on this device, the key that unlocks it is still here, and it no longer opens — which should not be possible.\n\nThat makes it worth reporting rather than just redoing. Your tags and their location history are not affected, and nothing was removed from your Apple account. + Key alignment + Index %1$d, aligned %2$s + None stored — the next fetch searches from the pairing date diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/parse/AccessoryAlignmentTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/parse/AccessoryAlignmentTest.java new file mode 100644 index 00000000..a8f0f152 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/parse/AccessoryAlignmentTest.java @@ -0,0 +1,85 @@ +package dev.wander.android.opentagviewer.util.parse; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +import java.time.Instant; + +/** + * Reading the alignment a fetch left behind, out of FindMy.py's serialised accessory. + * + *

This is the value that decides whether the "Locating your tags" banner is telling the truth, + * and the one the debug panel shows. Both were reading the export's record instead, which never + * moves. + */ +public class AccessoryAlignmentTest { + + /** The shape FindMy.py's FindMyAccessory.to_json writes, trimmed to what is read here. */ + private static String accessory(final String alignmentDate, final String alignmentIndex) { + return "{\"type\":\"accessory\",\"master_key\":\"aa\",\"skn\":\"bb\",\"sks\":\"cc\"," + + "\"paired_at\":\"2024-03-11T09:00:00+00:00\",\"name\":\"Keys\"," + + "\"model\":\"AirTag\",\"identifier\":\"x\",\"group_identifier\":null," + + "\"serial_number\":\"HK7Q2M4XLPNV\"," + + "\"alignment_date\":" + alignmentDate + "," + + "\"alignment_index\":" + alignmentIndex + "}"; + } + + @Test + public void itreadsTheDateAndIndexAFetchWroteBack() { + final String json = accessory("\"2026-09-02T07:15:00+00:00\"", "51234"); + + assertEquals(Instant.parse("2026-09-02T07:15:00Z").toEpochMilli(), + (long) AccessoryAlignment.alignedAtMillis(json)); + assertEquals(Integer.valueOf(51234), AccessoryAlignment.alignedIndex(json)); + } + + /** + * FindMy.py writes {@code datetime.isoformat()}, which carries an offset rather than a Z. + * + *

{@code Instant.parse} rejects that, so a reader written against the obvious API would + * answer null for every real accessory and quietly reinstate the bug this fixes. + */ + @Test + public void anoffsetIsAcceptedAsWellAsZuluTime() { + final long withOffset = AccessoryAlignment.alignedAtMillis( + accessory("\"2026-09-02T09:15:00+02:00\"", "1")); + final long withZ = AccessoryAlignment.alignedAtMillis( + accessory("\"2026-09-02T07:15:00Z\"", "1")); + + assertEquals(withZ, withOffset); + } + + /** A tag imported and never fetched. Ordinary, and not an error. */ + @Test + public void anaccessoryThatHasNeverBeenAlignedAnswersNull() { + final String json = accessory("null", "null"); + + assertNull(AccessoryAlignment.alignedAtMillis(json)); + assertNull(AccessoryAlignment.alignedIndex(json)); + } + + /** Rows predating the FindMy 0.9.x upgrade carry no accessory JSON at all. */ + @Test + public void nothingAtAllAnswersNullRatherThanThrowing() { + assertNull(AccessoryAlignment.alignedAtMillis(null)); + assertNull(AccessoryAlignment.alignedIndex(null)); + assertNull(AccessoryAlignment.alignedAtMillis("")); + assertNull(AccessoryAlignment.alignedIndex(" ")); + } + + /** + * Unreadable input is unknown, not a crash. + * + *

This runs to decide whether to show a banner and to fill a debug row. Neither is worth + * failing a fetch over. + */ + @Test + public void rubbishIsUnknownRatherThanAFailure() { + assertNull(AccessoryAlignment.alignedAtMillis("not json")); + assertNull(AccessoryAlignment.alignedIndex("{\"alignment_index\":\"not a number\"}")); + assertNull(AccessoryAlignment.alignedAtMillis("{\"alignment_date\":\"yesterday\"}")); + assertNull(AccessoryAlignment.alignedAtMillis("{}")); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetchTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetchTest.java index c0d15cb8..bbfc2bef 100644 --- a/app/src/test/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetchTest.java +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/rx/SlowFirstFetchTest.java @@ -1,6 +1,8 @@ package dev.wander.android.opentagviewer.util.rx; +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; @@ -100,4 +102,54 @@ public void anAlignmentInTheFutureIsTreatedAsFresh() { SlowFirstFetch.isLikely( Collections.singletonList(NOW + TimeUnit.DAYS.toMillis(2)), NOW)); } + + /** + * The bug this pair of methods exists to stop. + * + *

A tag exported a month ago and fetched this morning is not a slow fetch, but the export's + * record still says a month. Reading only the record showed the banner on every single + * refresh, for anybody whose export was more than a week old, however healthy their tags. + */ + @Test + public void arecentFetchBeatsAnOldExportRecord() { + final long now = 1_700_000_000_000L; + final long thisMorning = now - TimeUnit.HOURS.toMillis(6); + final long aMonthAgo = now - TimeUnit.DAYS.toMillis(30); + + assertFalse("the export is old, but the keys are aligned to this morning", + SlowFirstFetch.isLikely( + List.of(SlowFirstFetch.laterOf(thisMorning, aMonthAgo)), now)); + } + + /** And a tag never fetched still leans on whatever the export knew. */ + @Test + public void theexportRecordStillCountsBeforeTheFirstFetch() { + final long now = 1_700_000_000_000L; + + assertEquals(Long.valueOf(now - TimeUnit.DAYS.toMillis(2)), + SlowFirstFetch.laterOf(null, now - TimeUnit.DAYS.toMillis(2))); + assertFalse(SlowFirstFetch.isLikely( + List.of(SlowFirstFetch.laterOf(null, now - TimeUnit.DAYS.toMillis(2))), now)); + } + + /** Neither known is the slowest case there is, and must stay slow. */ + @Test + public void neitherKnownIsStillSlow() { + final long now = 1_700_000_000_000L; + + assertNull(SlowFirstFetch.laterOf(null, null)); + assertTrue(SlowFirstFetch.isLikely( + Collections.singletonList(SlowFirstFetch.laterOf(null, null)), now)); + } + + /** A re-import can carry a newer record than a stale accessory blob. */ + @Test + public void afreshReimportBeatsAStaleAccessory() { + final long now = 1_700_000_000_000L; + final long yesterday = now - TimeUnit.DAYS.toMillis(1); + final long aYearAgo = now - TimeUnit.DAYS.toMillis(365); + + assertEquals(Long.valueOf(yesterday), SlowFirstFetch.laterOf(aYearAgo, yesterday)); + assertFalse(SlowFirstFetch.isLikely(List.of(SlowFirstFetch.laterOf(aYearAgo, yesterday)), now)); + } }