From 5e28c21c2c87676a9868e69720d1eccf3ead2158 Mon Sep 17 00:00:00 2001 From: Ruben Barkow-Kuder Date: Wed, 14 Oct 2026 11:25:09 +0200 Subject: [PATCH 1/3] List every Find My device in earshot, not just the imported ones The app's BLE code only answers "is this one of my tags" - NearbyTagWatcher drops any frame whose address is not in the rolling-key index. This adds the other question, "what is out there at all", behind a new menu entry on the devices list. - ble/FindMyFrameKey: reconstructs the advertisement key from the 0x12 TLV and the BLE address it arrived on, for both payload shapes. Separated (len 0x19) yields the full 28-byte key over MAC + payload; nearby (len 0x02) yields only the six-byte prefix - which is the join key across both shapes inside one rotation, and finer than the MAC alone because it carries the two key bits the address cannot express. Walks the Apple TLV chain rather than reading offset zero, since iPhones prepend their own payloads. - ble/NearbyDeviceSighting + NearbyDeviceScanner: the generic counterpart of NearbyTagWatcher - same hardware scan filter (0x004C + type 0x12), same 20-minute scan restart ahead of the platform's long-scan downgrade, but no index, no MAC resolver, no Python. Emits every frame that parses. - ble/NearbyDeviceBook: one row per key prefix, newest sighting wins, pruned after 30s of silence - a tag that leaves sends nothing, so silence is the only departure signal. Sorted strongest signal first. - ble/ForeignTagNames: user names for foreign devices in SharedPreferences (deliberately not Room - a twelve-hex-chars-to-name map is what it is for, and a schema change would mean a migration). Keyed on the prefix so a name survives the separated/nearby flip; rotation re-keying is a different feature (rotation-tracking branch). - util/nearby/ProximityDistance: RSSI to rough metres (log-distance path loss, -59 dBm at 1m, rendered with "~" - honest about the precision the physics carries). - NearbyDevicesActivity + layout + NearbyDeviceListAdaptor + row layout: the screen the scanner exists for. Subscribes only while on top, rebuilds the list on sightings and on a 1s tick so departures age out live. Long-press renames via the same name dialog the tag page uses. BLE permission is asked once per screen, via BlePermissions like Maps. - Manifest declares the activity; my_devices_menu gains the entry, worded as a scan so nobody mistakes it for a network lookup. - 8 new strings across all 10 locales via add_strings.py. - Tests: FindMyFrameKeyTest pins the key layout for both shapes, the prefix join between them, the non-leading-TLV walk, and rejects junk; NearbyDeviceBookTest pins newest-wins, stale-resend, sort and pruning. Not verified against a physical tag: no AirTag was at hand during development. The key reconstruction follows the layout FindMy.py's scanner.py implements and is pinned by JVM tests; frames where 0x12 does not lead the manufacturer blob are parsed by FindMyFrameKey but currently dropped upstream of it (see NearbyDeviceSighting.tlvBytes). Co-Authored-By: Devin/SWE-2 <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/src/main/AndroidManifest.xml | 5 + .../opentagviewer/MyDevicesListActivity.java | 4 + .../opentagviewer/NearbyDevicesActivity.java | 225 ++++++++++++++++++ .../opentagviewer/ble/FindMyFrameKey.java | 197 +++++++++++++++ .../opentagviewer/ble/ForeignTagNames.java | 61 +++++ .../opentagviewer/ble/NearbyDeviceBook.java | 65 +++++ .../ble/NearbyDeviceScanner.java | 187 +++++++++++++++ .../ble/NearbyDeviceSighting.java | 108 +++++++++ .../NearbyDeviceListAdaptor.java | 136 +++++++++++ .../util/nearby/ProximityDistance.java | 51 ++++ .../res/layout/activity_nearby_devices.xml | 63 +++++ .../res/layout/nearby_device_list_item.xml | 61 +++++ app/src/main/res/menu/my_devices_menu.xml | 9 + 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 + .../opentagviewer/ble/FindMyFrameKeyTest.java | 123 ++++++++++ .../ble/NearbyDeviceBookTest.java | 73 ++++++ 25 files changed, 1448 insertions(+) create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/NearbyDevicesActivity.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyFrameKey.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/ForeignTagNames.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBook.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceScanner.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceSighting.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ui/nearbydevices/NearbyDeviceListAdaptor.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/nearby/ProximityDistance.java create mode 100644 app/src/main/res/layout/activity_nearby_devices.xml create mode 100644 app/src/main/res/layout/nearby_device_list_item.xml create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/ble/FindMyFrameKeyTest.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBookTest.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ac5c7b20..220312ae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -162,6 +162,11 @@ android:name=".MyDevicesListActivity" android:exported="false" android:theme="@style/Theme.OpenTagViewer.GenericGreyishActivity" /> + The screen {@link NearbyDeviceScanner} exists for. It subscribes while it is on top + * - the radio listens only while somebody is looking, which is what keeps a display feature + * from being a recording - and shows one row per key prefix through {@link NearbyDeviceBook}, + * named via {@link ForeignTagNames} when the user has renamed it. + * + *

The list is refreshed on two triggers, and needs both. New sightings arrive from the scan + * subscription, but the other half of a live list is *departures*: a tag that leaves range + * sends nothing, so a periodic pass rebuilds the snapshot for the pruning and the "last seen" + * age even while the air is quiet. + */ +public class NearbyDevicesActivity extends AppCompatActivity { + private static final String TAG = NearbyDevicesActivity.class.getSimpleName(); + + private static final int NEARBY_PERMISSION_REQUEST_CODE = 0x4E42; + + /** + * How often the list is rebuilt while open, independent of arriving frames. One second is + * what makes the "last seen" column read like a clock rather than a frozen snapshot, and it + * costs a sort over a handful of rows. + */ + private static final long REFRESH_INTERVAL_MS = TimeUnit.SECONDS.toMillis(1); + + private final NearbyDeviceScanner scanner = new NearbyDeviceScanner(); + private final NearbyDeviceBook book = new NearbyDeviceBook(); + private ForeignTagNames names; + + private ActivityNearbyDevicesBinding binding; + private NearbyDeviceListAdaptor adaptor; + + private Disposable scanSubscription; + private Disposable refreshSubscription; + + /** Asked at most once per screen, for the same reason MapsActivity bounds its own request. */ + private boolean blePermissionRequested = false; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + this.names = new ForeignTagNames(this); + + this.binding = DataBindingUtil.setContentView(this, R.layout.activity_nearby_devices); + WindowPaddingUtil.insetForSystemBars(this.binding.getRoot()); + this.binding.setHandleClickBack(this::finish); + + if (this.getSupportActionBar() != null) { + this.getSupportActionBar().hide(); + } + + this.adaptor = new NearbyDeviceListAdaptor( + this.getResources(), + this.names::nameFor, + this::onDeviceClicked, + this::onDeviceLongPressed); + + final RecyclerView list = findViewById(R.id.nearby_devices_list); + list.setLayoutManager(new LinearLayoutManager(this)); + list.setAdapter(this.adaptor); + } + + @Override + protected void onResume() { + super.onResume(); + + if (!BlePermissions.granted(this)) { + if (!this.blePermissionRequested) { + this.blePermissionRequested = true; + Log.d(TAG, "Requesting BLE permission(s) to scan nearby devices"); + ActivityCompat.requestPermissions( + this, BlePermissions.required(), NEARBY_PERMISSION_REQUEST_CODE); + } + this.showEmptyState(R.string.nearby_devices_needs_permission); + return; + } + + this.scanSubscription = this.scanner.scan(this.getApplicationContext()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::onSighting, + error -> Log.e(TAG, "Nearby device scan failed", error)); + + this.refreshSubscription = Observable + .interval(REFRESH_INTERVAL_MS, TimeUnit.MILLISECONDS, Schedulers.computation()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(tick -> this.rebuildList(), + error -> Log.e(TAG, "Nearby device refresh failed", error)); + } + + @Override + protected void onPause() { + if (this.scanSubscription != null) { + this.scanSubscription.dispose(); + this.scanSubscription = null; + } + if (this.refreshSubscription != null) { + this.refreshSubscription.dispose(); + this.refreshSubscription = null; + } + super.onPause(); + } + + @Override + public void onRequestPermissionsResult( + final int requestCode, + @NonNull final String[] permissions, + @NonNull final int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode != NEARBY_PERMISSION_REQUEST_CODE) { + return; + } + // Granted: the next onResume starts the scan. Denied: the empty state already explains + // what is missing - re-prompting here would loop. + if (BlePermissions.granted(this)) { + this.onResume(); + } + } + + private void onSighting(final NearbyDeviceSighting sighting) { + this.book.hear(sighting); + this.rebuildList(); + } + + private void rebuildList() { + final List rows = + this.book.snapshot(System.currentTimeMillis()); + + if (rows.isEmpty()) { + this.showEmptyState(R.string.nearby_devices_empty); + return; + } + + this.binding.nearbyDevicesList.setVisibility(VISIBLE); + this.binding.nearbyDevicesEmptyState.setVisibility(GONE); + this.adaptor.setRows(rows); + this.adaptor.notifyDataSetChanged(); + } + + private void showEmptyState(final int textRes) { + this.binding.nearbyDevicesList.setVisibility(GONE); + this.binding.nearbyDevicesEmptyState.setVisibility(VISIBLE); + this.binding.nearbyDevicesEmptyText.setText(textRes); + } + + /** Reserved for the radar branch: clicking a row will open a pointing view for this key. */ + private void onDeviceClicked(final NearbyDeviceSighting sighting) { + Log.d(TAG, "Clicked nearby device " + sighting.getKeyPrefix()); + } + + private void onDeviceLongPressed(final View anchor, final NearbyDeviceSighting sighting) { + this.showRenameDialog(sighting); + } + + /** + * The same name dialog the tag page uses, pointed at a prefix instead of a naming record: + * a name for a foreign tag lives only in this app, on the only identity it broadcasts. + */ + private void showRenameDialog(final NearbyDeviceSighting sighting) { + final View view = inflate(this, R.layout.edit_device_name_dialog, null); + final TextInputEditText textInput = view.findViewById(R.id.device_name_input); + final String existing = this.names.nameFor(sighting.getKeyPrefix()); + if (existing != null) { + textInput.setText(existing); + } + + final AlertDialog dialog = new MaterialAlertDialogBuilder(this) + .setTitle(R.string.device_name) + .setIcon(R.drawable.edit_24px) + .setView(view) + .setPositiveButton(R.string.confirm, (d, which) -> { + this.names.rename(sighting.getKeyPrefix(), + Objects.requireNonNull(textInput.getText()).toString()); + this.rebuildList(); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + + // No empty-field guard here, unlike the tag page's copy: a foreign tag has no upstream + // name to overwrite, so a blank field is the only "unnamed" there is - confirm clears. + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyFrameKey.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyFrameKey.java new file mode 100644 index 00000000..8da33c59 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyFrameKey.java @@ -0,0 +1,197 @@ +package dev.wander.android.opentagviewer.ble; + +import androidx.annotation.Nullable; + +import lombok.Getter; + +/** + * The advertisement key a Find My frame is broadcasting, reconstructed from the frame and the + * BLE address it arrived on. + * + *

Why this is separate from {@link FindMyAdvertisement}. That class answers "what did + * the tag say about itself" - owner-present, battery, the raw status byte - and deliberately + * does not touch the key, because the only reader that wanted keys resolves them a different + * way: {@code NearbyTagWatcher} knows which of the user's own tags to expect and matches on + * derived MAC. A scanner that wants to show *every* Find My device has no expected set, so the + * key itself is the identity. That is a different question, asked by different callers, and it + * gets its own class rather than growing a second responsibility inside the parser. + * + *

The layout is the one FindMy.py's {@code scanner.py} implements. A 0x12 frame is a + * type byte, a length byte, then a payload in one of two shapes: + * + *

    + *
  • Separated ({@code len == 0x19}): the tag's owner is away and it begs to be found. + * The payload is {@code status | pubkey[6..27] (22B) | topTwoBits | hint}; the first six + * key bytes never travel in the payload at all - they *are* the BLE address, with the top + * two bits of key byte 0 living in the payload's {@code topTwoBits} byte. Reassembly is + * {@code key[0] = (topTwo << 6) | (mac[0] & 0x3f)}, {@code key[1..5] = mac[1..5]}, + * {@code key[6..27] = payload[1..22]}.
  • + *
  • Nearby ({@code len == 0x02}): the owner is close. The payload is only + * {@code status | topTwoBits}, and the same formula reconstructs just the six-byte prefix: + * {@code key[0] = (topTwo << 6) | (mac[0] & 0x3f)}, {@code key[1..5] = mac[1..5]}.
  • + *
+ * + *

The six-byte prefix is the join key across both shapes. Within one key period a tag + * moving between separated and nearby broadcasts the same advertisement key, so the prefix + * reconstructed identically from either shape is what ties its frames to one identity - and it + * is finer than the MAC alone, because the two bits the MAC cannot carry arrive in the payload. + * Everything about rotation is somebody else's problem: after the ~15 minute roll the prefix is + * a different identity, which is exactly what the rotation heuristic elsewhere keys on. + * + *

Apple manufacturer data is a TLV chain, not one frame. An iPhone routinely sends + * several Apple payloads concatenated in the same {@code 0x004C} blob; the Find My frame is not + * guaranteed to sit first. This walks the chain and takes the first {@code 0x12}, where + * {@link FindMyAdvertisement#parse} reads offset zero - the difference matters on the crowded + * end of a scan, which is precisely where a generic scanner runs. + * + *

Pure parsing, no Android in it: every rule above is covered by a JVM test. + */ +@Getter +public final class FindMyFrameKey { + + /** Apple's offline-finding advertisement type, first byte of the Find My TLV. */ + static final byte TYPE_OFFLINE_FINDING = 0x12; + + /** Length byte of the separated (full-beacon) payload shape. */ + static final int LEN_SEPARATED = 0x19; + + /** Length byte of the nearby (short) payload shape. */ + static final int LEN_NEARBY = 0x02; + + /** The reconstructed six-byte prefix, shared by both payload shapes. */ + private final byte[] keyPrefix; + + /** The whole 28-byte advertisement key, or null on a nearby frame that does not carry it. */ + @Nullable + private final byte[] fullKey; + + /** The separated frame's hint byte; 0 on a nearby frame, which carries none. */ + private final int hint; + + /** The status byte the frame opened with, kept raw for the same reason it is kept on the advertisement. */ + private final int statusByte; + + private FindMyFrameKey( + final byte[] keyPrefix, + @Nullable final byte[] fullKey, + final int hint, + final int statusByte) { + this.keyPrefix = keyPrefix; + this.fullKey = fullKey; + this.hint = hint; + this.statusByte = statusByte; + } + + /** + * Reconstructs the key from a Find My TLV and the MAC it was heard on, or null if this is + * not a Find My frame or is too short to read. + * + * @param appleManufacturerData the whole {@code 0x004C} manufacturer blob - a TLV chain + * @param macAddress the BLE address, {@code "AA:BB:CC:DD:EE:FF"} + */ + @Nullable + public static FindMyFrameKey parse( + @Nullable final byte[] appleManufacturerData, @Nullable final String macAddress) { + final byte[] mac = macBytes(macAddress); + if (mac == null) { + return null; + } + + final int tlvStart = findFindMyTlv(appleManufacturerData); + if (tlvStart < 0) { + return null; + } + + // tlvStart points at the type byte; +1 is the length, +2 the payload. + final int payloadLen = appleManufacturerData[tlvStart + 1] & 0xFF; + final byte[] payload = new byte[payloadLen]; + System.arraycopy(appleManufacturerData, tlvStart + 2, payload, 0, payloadLen); + + if (payloadLen == LEN_NEARBY) { + final byte[] prefix = new byte[6]; + prefix[0] = (byte) ((payload[1] << 6) | (mac[0] & 0x3F)); + System.arraycopy(mac, 1, prefix, 1, 5); + return new FindMyFrameKey(prefix, null, 0, payload[0] & 0xFF); + } + + if (payloadLen == LEN_SEPARATED) { + final byte[] key = new byte[28]; + key[0] = (byte) ((payload[23] << 6) | (mac[0] & 0x3F)); + System.arraycopy(mac, 1, key, 1, 5); + System.arraycopy(payload, 1, key, 6, 22); + + final byte[] prefix = new byte[6]; + System.arraycopy(key, 0, prefix, 0, 6); + return new FindMyFrameKey(prefix, key, payload[24] & 0xFF, payload[0] & 0xFF); + } + + // A 0x12 with neither known length: not a shape we can reconstruct from. + return null; + } + + /** + * Walks the TLV chain for the first offline-finding frame. + * + * @return index of the {@code 0x12} type byte, or -1. Indexing past the returned value is + * safe only if the caller checked {@code +2 + len <= data.length} itself - which is + * why this also bounds-checks each TLV before returning it. + */ + private static int findFindMyTlv(@Nullable final byte[] data) { + if (data == null) { + return -1; + } + + int offset = 0; + while (offset + 2 <= data.length) { + final int type = data[offset]; + final int len = data[offset + 1] & 0xFF; + if (offset + 2 + len > data.length) { + // Truncated tail: nothing readable left. + return -1; + } + if (type == TYPE_OFFLINE_FINDING) { + return offset; + } + offset += 2 + len; + } + return -1; + } + + /** {@code "AA:BB:CC:DD:EE:FF"} to six bytes, or null on anything else. */ + @Nullable + private static byte[] macBytes(@Nullable final String macAddress) { + if (macAddress == null || macAddress.length() != 17) { + return null; + } + final byte[] mac = new byte[6]; + try { + for (int i = 0; i < 6; i++) { + mac[i] = (byte) Integer.parseInt( + macAddress.substring(i * 3, i * 3 + 2), 16); + } + } catch (final NumberFormatException notHex) { + return null; + } + return mac; + } + + /** Lowercase hex, the form a name store and a list row both key on. */ + public String keyPrefixHex() { + return hex(this.keyPrefix); + } + + /** Lowercase hex of the full key, or null on a nearby frame - same rule as {@link #fullKey}. */ + @Nullable + public String fullKeyHex() { + return this.fullKey == null ? null : hex(this.fullKey); + } + + private static String hex(final byte[] bytes) { + final StringBuilder out = new StringBuilder(bytes.length * 2); + for (final byte b : bytes) { + out.append(Character.forDigit((b >> 4) & 0xF, 16)); + out.append(Character.forDigit(b & 0xF, 16)); + } + return out.toString(); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/ForeignTagNames.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/ForeignTagNames.java new file mode 100644 index 00000000..d470c13a --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/ForeignTagNames.java @@ -0,0 +1,61 @@ +package dev.wander.android.opentagviewer.ble; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.annotation.Nullable; + +import java.util.HashMap; +import java.util.Map; + +/** + * The names a user gave foreign Find My devices, by key prefix. + * + *

SharedPreferences, and deliberately not Room. A schema change in Room means a + * migration, a migration test and a committed schema - the project's first rule - and a map + * from twelve hex characters to a name is precisely the thing SharedPreferences exists for. + * The prefix is the only key a foreign tag offers until it rotates, and when it does the name + * is simply keyed to bytes nobody broadcasts any more; re-joining a rotated tag to its old + * name is a different feature, not a schema problem. + * + *

Prefix-keyed, not MAC-keyed: the prefix is what stays constant when a tag flips between + * nearby and separated frames inside one rotation, so a name survives that flip for free. + */ +public final class ForeignTagNames { + + private static final String PREFERENCES = "foreign_tag_names"; + private static final String KEY_PREFIX = "name_"; + + private final SharedPreferences preferences; + + public ForeignTagNames(final Context context) { + this.preferences = context.getApplicationContext() + .getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE); + } + + @Nullable + public String nameFor(final String keyPrefix) { + return this.preferences.getString(KEY_PREFIX + keyPrefix, null); + } + + /** Names an identity; an empty or blank name removes it instead. */ + public void rename(final String keyPrefix, @Nullable final String name) { + if (name == null || name.isBlank()) { + this.preferences.edit().remove(KEY_PREFIX + keyPrefix).apply(); + return; + } + this.preferences.edit().putString(KEY_PREFIX + keyPrefix, name.trim()).apply(); + } + + /** Everything currently named, prefix → name, e.g. for an export. */ + public Map all() { + final Map names = new HashMap<>(); + for (final Map.Entry entry : this.preferences.getAll().entrySet()) { + if (entry.getKey().startsWith(KEY_PREFIX) && entry.getValue() instanceof String) { + names.put(entry.getKey().substring(KEY_PREFIX.length()), + (String) entry.getValue()); + } + } + return names; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBook.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBook.java new file mode 100644 index 00000000..69d4684d --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBook.java @@ -0,0 +1,65 @@ +package dev.wander.android.opentagviewer.ble; + +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The running table behind the nearby-devices list: the newest sighting per key prefix. + * + *

A Find My tag in range advertises every one to three seconds. Showing each one would be a + * blur of duplicate rows, so the list wants one row per identity - the key prefix - holding the + * most recent sighting, which carries the freshest RSSI, state and timestamp for display. Older + * sightings are not kept: branch 3's rotation heuristic wants a log with history, but this book + * is for what is on screen *now*, and a screen that is open for minutes remembers a lot. + * + *

Pure Java - no Android, no clock: {@link NearbyDeviceSighting} carries its own timestamp, + * and the caller supplies "now" when it asks how stale the book is. That keeps the whole + * aggregation rule under a JVM test. + */ +public final class NearbyDeviceBook { + + /** + * How long a prefix may go unheard before its row counts as gone. + * + *

Deliberately shorter than any rotation: a vanished prefix is almost always out of + * range or quieted, and holding it for 15 minutes would list departed tags as present. + * Rows that cross this are pruned by {@link #snapshot}, not by the scan - silence is the + * only signal disappearance has. + */ + public static final long GONE_AFTER_MS = 30_000; + + private final Map latestByPrefix = new LinkedHashMap<>(); + + /** Records a sighting under its prefix; the newest one wins the row. */ + public void hear(final NearbyDeviceSighting sighting) { + final NearbyDeviceSighting previous = this.latestByPrefix.get(sighting.getKeyPrefix()); + if (previous == null || sighting.getSeenAtMs() >= previous.getSeenAtMs()) { + this.latestByPrefix.put(sighting.getKeyPrefix(), sighting); + } + } + + /** + * The current rows, strongest signal first. + * + *

Prunes prefixes unheard for {@link #GONE_AFTER_MS} first: a tag that left should fall + * out of the list, not linger with a stale RSSI implying it is still here. + */ + public List snapshot(final long nowMs) { + this.latestByPrefix.values().removeIf(s -> nowMs - s.getSeenAtMs() > GONE_AFTER_MS); + + final List rows = new ArrayList<>(this.latestByPrefix.values()); + rows.sort(Comparator.comparingInt(NearbyDeviceSighting::getRssi).reversed()); + return rows; + } + + /** The newest sighting for one prefix, or null. */ + @Nullable + public NearbyDeviceSighting latest(final String keyPrefix) { + return this.latestByPrefix.get(keyPrefix); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceScanner.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceScanner.java new file mode 100644 index 00000000..83923681 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceScanner.java @@ -0,0 +1,187 @@ +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.ScanFilter; +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.List; +import java.util.concurrent.TimeUnit; + +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * Reports every Find My device in earshot, for as long as somebody is subscribed. + * + *

The generic counterpart of {@link NearbyTagWatcher}. The watcher hears the same + * radio traffic and throws most of it away - anything whose address is not in the user's own + * tag index - because answering "is this mine" is its whole job. This class answers the other + * question, "what is out there at all", so it emits every frame that parses as a Find My + * advertisement. It shares the watcher's scan shape deliberately: same hardware filter (the + * controller discards non-Find-My frames so the callback only fires for ours), same restart + * before the platform's long-scan downgrade, same contract of completing rather than erroring + * when Bluetooth or permission is missing. + * + *

What it does not share is anything expensive. No index, no MAC resolver, no Python - + * a frame goes from the radio to the emitter as pure byte parsing, which is also why this can + * sit on the scan callback thread without an offload: {@link FindMyFrameKey#parse} is a handful + * of array copies, not a derivation. + * + *

Emits once per advertisement, not once per device - the caller wants live signal strength + * and fresh timestamps, the same contract the watcher's sightings carry. + */ +public class NearbyDeviceScanner { + private static final String TAG = NearbyDeviceScanner.class.getSimpleName(); + + /** Injectable so a test can drive timestamps without a clock. */ + interface Clock { + long nowMs(); + } + + /** How hard the radio listens; low latency while a screen is open, see the watcher. */ + private volatile int scanMode; + + private final Clock clock; + + public NearbyDeviceScanner() { + this(ScanSettings.SCAN_MODE_LOW_LATENCY, System::currentTimeMillis); + } + + public NearbyDeviceScanner(final int scanMode) { + this(scanMode, System::currentTimeMillis); + } + + NearbyDeviceScanner(final int scanMode, final Clock clock) { + this.scanMode = scanMode; + this.clock = clock; + } + + /** + * Emits a {@link NearbyDeviceSighting} for every Find My frame heard. + * + *

Never errors on an ordinary failure, matching {@link NearbyTagWatcher#watch}: no + * permission, Bluetooth off, or a failed scan all complete the stream, because the caller + * has nothing to do about them that it does not already do. + */ + @SuppressLint("MissingPermission") + public Observable scan(final Context context) { + return Observable.create(emitter -> { + if (!BlePermissions.granted(context)) { + Log.d(TAG, "Not scanning nearby devices: BLE permission not granted"); + emitter.onComplete(); + return; + } + + 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 scanning nearby devices: Bluetooth is off or unsupported"); + emitter.onComplete(); + return; + } + + final ScanCallback callback = new ScanCallback() { + @Override + public void onScanResult(final int callbackType, final ScanResult result) { + final NearbyDeviceSighting sighting = sightingFrom(result); + if (sighting == null || emitter.isDisposed()) { + return; + } + emitter.onNext(sighting); + } + + @Override + public void onScanFailed(final int errorCode) { + Log.w(TAG, "Nearby device scan failed (errorCode=" + errorCode + ")"); + if (!emitter.isDisposed()) { + emitter.onComplete(); + } + } + }; + + // Filtered in hardware for the same reason the watcher filters: the controller + // drops every non-Find-My frame itself, so an ordinary flat's dozens of stray BLE + // advertisements never reach this callback at all. + final List findMyFramesOnly = List.of(new ScanFilter.Builder() + .setManufacturerData(FindMyAdvertisement.APPLE_COMPANY_ID, + new byte[]{FindMyAdvertisement.TYPE_OFFLINE_FINDING}, + new byte[]{(byte) 0xFF}) + .build()); + scanner.startScan(findMyFramesOnly, settingsFor(this.scanMode), callback); + + // Same downgrade the watcher restarts ahead of: past 30 minutes a continuous scan + // silently becomes opportunistic, which for a "what is around me" screen reads as + // "suddenly nothing is around". One stop/start pair per 20 minutes stays far inside + // the platform's start-rate 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, settingsFor(this.scanMode), callback); + } catch (final Exception bluetoothWentAway) { + Log.w(TAG, "Could not restart the nearby device scan", + bluetoothWentAway); + if (!emitter.isDisposed()) { + emitter.onComplete(); + } + } + }); + + emitter.setCancellable(() -> { + Log.d(TAG, "Stopped scanning for nearby devices"); + scanRefresh.dispose(); + try { + scanner.stopScan(callback); + } catch (final Exception bluetoothWentAway) { + // The adapter ended the scan itself; there is nothing left to stop, and a + // throw here has no subscriber to deliver to - it kills the process. + Log.d(TAG, "The nearby device scan had already ended", + bluetoothWentAway); + } + }); + }).subscribeOn(Schedulers.io()); + } + + private static ScanSettings settingsFor(final int mode) { + return new ScanSettings.Builder().setScanMode(mode).build(); + } + + /** Kept in sync with {@link NearbyTagWatcher#SCAN_RESTART_INTERVAL_MS} for the same reason. */ + static final long SCAN_RESTART_INTERVAL_MS = TimeUnit.MINUTES.toMillis(20); + + /** + * One scan result turned into a sighting, or null if it is not Find My. + * + *

Package-private so a JVM test can feed raw frames without a radio. + */ + @Nullable + NearbyDeviceSighting sightingFrom(final ScanResult result) { + final ScanRecord record = result.getScanRecord(); + if (record == null || result.getDevice() == null) { + return null; + } + + return NearbyDeviceSighting.fromAdvertisement( + result.getDevice().getAddress(), + record.getManufacturerSpecificData(FindMyAdvertisement.APPLE_COMPANY_ID), + result.getRssi(), + this.clock.nowMs()); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceSighting.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceSighting.java new file mode 100644 index 00000000..967fa0f3 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceSighting.java @@ -0,0 +1,108 @@ +package dev.wander.android.opentagviewer.ble; + +import androidx.annotation.Nullable; + +import lombok.Builder; +import lombok.Getter; + +/** + * Any Find My device this phone's radio just heard - most of them strangers. + * + *

What this is not: {@link NearbyTagSighting}. That class is a sighting of one of the + * user's *own* tags, resolved through the rolling-key index so it knows the beacon it heard. + * This one knows only what the air said: a key prefix, a MAC, a signal strength. The device + * behind it belongs to anybody - or is one of the user's own seen before the index caught it. + * The two coexist on purpose rather than one growing a "maybe unidentified" mode: matching is + * the watcher's whole contract, and not matching is this one's. + * + *

{@link #keyPrefix} is the identity a caller should join on: the same device in nearby and + * separated states yields the same six bytes, and a different device almost never does. The MAC + * is carried because it is interesting on screen and for a GATT connect, but it is not the + * identity - the prefix is finer (it carries the two key bits the MAC cannot express), and + * {@link FindMyFrameKey} explains the reconstruction. + */ +@Getter +@Builder +public final class NearbyDeviceSighting { + + /** The BLE address the frame arrived on. Not an identity - see the class doc. */ + private final String mac; + + /** Six-byte advertisement-key prefix as lowercase hex; the join key for both frame shapes. */ + private final String keyPrefix; + + /** + * The whole advertisement key as lowercase hex, when the frame carried it. + * + *

Null on a nearby ({@code len == 0x02}) frame, which transmits only the prefix. Worth + * keeping because the separated form is the one a Find My network lookup could be built + * from, and because two prefixes that agree do not necessarily agree here. + */ + @Nullable + private final String fullKey; + + /** Signal strength in dBm. Negative; closer to zero is nearer. */ + private final int rssi; + + private final FindMyAdvertisement.BatteryLevel batteryLevel; + + /** The raw status byte, kept for the same reason {@link NearbyTagSighting} keeps it. */ + private final int statusByte; + + /** The separated frame's hint byte; 0 on a nearby frame. */ + private final int hint; + + /** Whether the tag said it was separated from its owner. */ + private final FindMyAdvertisement.State state; + + /** Wall-clock time of the sighting, for ageing rows on the list. */ + private final long seenAtMs; + + /** + * Builds a sighting from a scan result's raw parts, or null when the frame is not Find My. + * + *

Package-private and taking the pieces rather than {@code ScanResult}, so the JVM test + * parses bytes without constructing Android classes. + */ + @Nullable + static NearbyDeviceSighting fromAdvertisement( + final String mac, + @Nullable final byte[] appleManufacturerData, + final int rssi, + final long seenAtMs) { + final FindMyAdvertisement advertisement = + FindMyAdvertisement.parse(tlvBytes(appleManufacturerData)); + final FindMyFrameKey key = FindMyFrameKey.parse(appleManufacturerData, mac); + if (advertisement == null || key == null) { + return null; + } + + return NearbyDeviceSighting.builder() + .mac(mac) + .keyPrefix(key.keyPrefixHex()) + .fullKey(key.fullKeyHex()) + .rssi(rssi) + .batteryLevel(advertisement.getBatteryLevel()) + .statusByte(advertisement.getStatusByte()) + .hint(key.getHint()) + .state(advertisement.getState()) + .seenAtMs(seenAtMs) + .build(); + } + + /** + * The Find My TLV alone, or null when the blob does not start with one. + * + *

{@link FindMyAdvertisement#parse} reads offset zero, so it is handed the whole blob as + * long as the Find My frame leads it - which {@link FindMyFrameKey#parse} already verified + * by the time this runs on the same data. Frames where 0x12 is not first fall out the same + * {@code null} both checks would produce anyway. + */ + @Nullable + private static byte[] tlvBytes(@Nullable final byte[] data) { + if (data == null || data.length < 3 || data[0] != FindMyFrameKey.TYPE_OFFLINE_FINDING) { + return null; + } + return data; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/nearbydevices/NearbyDeviceListAdaptor.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/nearbydevices/NearbyDeviceListAdaptor.java new file mode 100644 index 00000000..b2389842 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/nearbydevices/NearbyDeviceListAdaptor.java @@ -0,0 +1,136 @@ +package dev.wander.android.opentagviewer.ui.nearbydevices; + +import android.content.res.Resources; +import android.text.format.DateUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.ble.FindMyAdvertisement; +import dev.wander.android.opentagviewer.ble.NearbyDeviceSighting; +import dev.wander.android.opentagviewer.util.nearby.ProximityDistance; +import lombok.Getter; +import java.util.function.Function; + +/** + * The rows of the nearby-devices list: one per key prefix, name-or-prefix on top. + * + *

Backed by a snapshot list the activity swaps wholesale rather than by the book itself - + * RecyclerView wants positions stable within a bind pass, and the book mutates on the scan + * thread, so the activity hands over an immutable copy and only then notifies. + */ +public class NearbyDeviceListAdaptor + extends RecyclerView.Adapter { + + /** Click: select the device (future radar). Long click: rename - carries the row view as anchor. */ + private final Consumer onClickCallback; + private final BiConsumer onLongClickCallback; + + /** prefix → user-given name; consulted on every bind so a rename shows on the next pass. */ + private final Function nameResolver; + + private final Resources resources; + + private final List rows = new ArrayList<>(); + + @Getter + public static class ViewHolder extends RecyclerView.ViewHolder { + private final FrameLayout container; + private final TextView deviceName; + private final TextView details; + private final TextView mac; + + public ViewHolder(@NonNull final View itemView) { + super(itemView); + this.container = itemView.findViewById(R.id.nearby_device_item_container); + this.deviceName = itemView.findViewById(R.id.nearby_item_device_name); + this.details = itemView.findViewById(R.id.nearby_item_details); + this.mac = itemView.findViewById(R.id.nearby_item_mac); + } + } + + public NearbyDeviceListAdaptor( + @lombok.NonNull final Resources resources, + @lombok.NonNull final Function nameResolver, + @lombok.NonNull final Consumer onClickCallback, + @lombok.NonNull final BiConsumer onLongClickCallback) { + this.resources = resources; + this.nameResolver = nameResolver; + this.onClickCallback = onClickCallback; + this.onLongClickCallback = onLongClickCallback; + } + + /** Replaces the whole snapshot; the caller notifies. */ + public void setRows(final List sightings) { + this.rows.clear(); + this.rows.addAll(sightings); + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.nearby_device_list_item, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull final ViewHolder holder, final int position) { + final NearbyDeviceSighting sighting = this.rows.get(position); + + final String givenName = this.nameResolver.apply(sighting.getKeyPrefix()); + final String title = givenName != null + ? givenName + : this.resources.getString(R.string.nearby_device_unnamed, + sighting.getKeyPrefix().substring(0, 4)); + holder.getDeviceName().setText(title); + + // The details line is assembled from localized pieces: distance, battery, owner-state, + // then how stale the newest hearing is. + final List parts = new ArrayList<>(); + parts.add(this.resources.getString(R.string.nearby_device_distance_format, + ProximityDistance.describe(sighting.getRssi()))); + parts.add(this.resources.getString(batteryString(sighting.getBatteryLevel()))); + parts.add(this.resources.getString(sighting.getState() == FindMyAdvertisement.State.SEPARATED + ? R.string.nearby_device_separated + : R.string.nearby_device_with_owner)); + parts.add(DateUtils.getRelativeTimeSpanString( + sighting.getSeenAtMs(), System.currentTimeMillis(), + DateUtils.SECOND_IN_MILLIS).toString()); + holder.getDetails().setText(String.join(" · ", parts)); + + holder.getMac().setText(sighting.getMac()); + + holder.getContainer().setOnClickListener(v -> this.onClickCallback.accept(sighting)); + holder.getContainer().setOnLongClickListener(v -> { + this.onLongClickCallback.accept(v, sighting); + return true; + }); + } + + private static int batteryString(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; + } + } + + @Override + public int getItemCount() { + return this.rows.size(); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/nearby/ProximityDistance.java b/app/src/main/java/dev/wander/android/opentagviewer/util/nearby/ProximityDistance.java new file mode 100644 index 00000000..9974e20f --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/nearby/ProximityDistance.java @@ -0,0 +1,51 @@ +package dev.wander.android.opentagviewer.util.nearby; + +import java.util.Locale; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * RSSI turned into a rough metres-away figure for a nearby Find My device. + * + *

A log-distance path-loss estimate, honest about being one. RSSI is what the radio + * reports, not a tape measure: the same tag at the same distance reads differently through a + * pocket, a wall, or held against the phone. The number exists to sort "about an arm's length" + * from "somewhere in the flat", and it is rendered with a "~" on screen so nobody reads more + * precision into it than the physics carries. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class ProximityDistance { + + /** + * RSSI the tag would read at exactly one metre, the model's calibration point. + * + *

-59 dBm is the conventional figure for a BLE beacon, and the same one AirGuard's + * distance estimate is built on. A real calibration knob is a radar-screen concern; the + * list only needs a plausible sort order. + */ + static final int TX_POWER_AT_1M_DBM = -59; + + /** Path-loss exponent folded into the denominator: free space would be 20, indoor ~25. */ + static final double PATH_LOSS_DENOMINATOR = 25.0; + + /** Estimated metres away. RSSI weaker than nothing meaningful is clamped to the far end. */ + public static double estimateMeters(final int rssi) { + if (rssi >= 0) { + return 1.0; + } + return Math.pow(10.0, (TX_POWER_AT_1M_DBM - rssi) / PATH_LOSS_DENOMINATOR); + } + + /** + * One decimal below ten metres, whole metres beyond it - the precision the physics allows, + * so "-45 dBm" reads "~2 m" rather than a false-precision "~2.3 m". + */ + public static String describe(final int rssi) { + final double meters = estimateMeters(rssi); + if (meters < 10.0) { + return String.format(Locale.US, "%.1f", meters); + } + return String.format(Locale.US, "%.0f", meters); + } +} diff --git a/app/src/main/res/layout/activity_nearby_devices.xml b/app/src/main/res/layout/activity_nearby_devices.xml new file mode 100644 index 00000000..71392963 --- /dev/null +++ b/app/src/main/res/layout/activity_nearby_devices.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/nearby_device_list_item.xml b/app/src/main/res/layout/nearby_device_list_item.xml new file mode 100644 index 00000000..20b04511 --- /dev/null +++ b/app/src/main/res/layout/nearby_device_list_item.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/my_devices_menu.xml b/app/src/main/res/menu/my_devices_menu.xml index cbe5f167..35d393e7 100644 --- a/app/src/main/res/menu/my_devices_menu.xml +++ b/app/src/main/res/menu/my_devices_menu.xml @@ -53,4 +53,13 @@ android:id="@+id/action_import_history" android:title="@string/import_history" /> + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6bda6f93..22564136 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -423,4 +423,12 @@ Wenn Apple die Anfrage lediglich abgelehnt hat, lohnt es sich, zu warten und es Klingeln fehlgeschlagen: %1$s Kein https: Das API-Token wird unverschlüsselt gesendet. So richtest du das ein + Geräte in der Nähe + Geräte in der Nähe scannen + Keine Find-My-Geräte in Reichweite. AirTags und andere Apple-Geräte erscheinen hier, solange dieser Bildschirm geöffnet ist. + Bluetooth-Zugriff erlauben, um Find-My-Geräte in der Nähe zu sehen. + Find-My-Gerät · %1$s + ~%1$s m + vom Besitzer getrennt + beim Besitzer \ 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 9bd596cf..b1017761 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -423,4 +423,12 @@ If Apple simply refused the request, waiting and trying again is worth doing fir Ring failed: %1$s Not https: the API token will be sent unencrypted. How to set this up + Nearby devices + Scan nearby devices + No Find My devices in range. AirTags and other Apple Find My devices appear here while this screen is open. + Allow Bluetooth access to see nearby Find My devices. + Find My device · %1$s + ~%1$s m + separated from its owner + with its owner \ 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 86ce02ed..e7ba2786 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -423,4 +423,12 @@ Si Apple a simplement refusé la requête, il vaut mieux attendre et réessayer. Échec de la sonnerie : %1$s Pas en https : le jeton d\'API sera envoyé sans chiffrement. Comment le configurer + Appareils à proximité + Rechercher des appareils à proximité + Aucun appareil Localiser à portée. Les AirTags et autres appareils Localiser d\'Apple apparaissent ici tant que cet écran est ouvert. + Autorisez l\'accès Bluetooth pour voir les appareils Localiser à proximité. + Appareil Localiser · %1$s + ~%1$s m + séparé de son propriétaire + avec son propriétaire \ 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 89f8366e..83ad7180 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -423,4 +423,12 @@ Appleが要求を拒否しただけなら、少し待ってからもう一度お 鳴らせませんでした: %1$s https ではありません。API トークンは暗号化されずに送信されます。 設定方法 + 近くのデバイス + 近くのデバイスをスキャン + 範囲内に「探す」デバイスがありません。AirTag やその他の Apple「探す」対応デバイスは、この画面を開いている間ここに表示されます。 + 近くの「探す」デバイスを表示するには Bluetooth へのアクセスを許可してください。 + 「探す」デバイス · %1$s + 約 %1$s m + 持ち主と離れています + 持ち主の近く \ 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 868a1313..267faac0 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -423,4 +423,12 @@ Apple이 요청을 거부한 것뿐이라면 잠시 기다렸다가 다시 시 소리 재생 실패: %1$s https가 아닙니다. API 토큰이 암호화되지 않은 채 전송됩니다. 설정 방법 + 근처 기기 + 근처 기기 검색 + 범위 내에 \'나의 찾기\' 기기가 없습니다. AirTag 및 기타 Apple \'나의 찾기\' 기기는 이 화면이 열려 있는 동안 여기에 표시됩니다. + 근처 \'나의 찾기\' 기기를 보려면 블루투스 접근을 허용하세요. + \'나의 찾기\' 기기 · %1$s + 약 %1$s m + 소유자와 떨어짐 + 소유자와 함께 있음 \ 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 d2e116d2..47a8e089 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -423,4 +423,12 @@ Als Apple het verzoek simpelweg weigerde, is even wachten en opnieuw proberen he Laten afgaan mislukt: %1$s Geen https: het API-token wordt onversleuteld verstuurd. Zo stel je dit in + Apparaten in de buurt + Apparaten in de buurt scannen + Geen Zoek-mijn-apparaten in bereik. AirTags en andere Apple Zoek mijn-apparaten verschijnen hier zolang dit scherm open is. + Sta Bluetooth-toegang toe om Zoek-mijn-apparaten in de buurt te zien. + Zoek mijn-apparaat · %1$s + ~%1$s m + gescheiden van de eigenaar + bij de eigenaar \ 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 96a992cb..6dba4209 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -423,4 +423,12 @@ Не удалось подать сигнал: %1$s Не https: токен API будет передан без шифрования. Как это настроить + Устройства рядом + Сканировать устройства рядом + Устройств «Локатор» в зоне досягаемости нет. AirTag и другие устройства Apple «Локатор» появляются здесь, пока открыт этот экран. + Разрешите доступ к Bluetooth, чтобы видеть устройства «Локатор» рядом. + Устройство «Локатор» · %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 5f1810a5..87c997a7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -423,4 +423,12 @@ 响铃失败:%1$s 不是 https:API 令牌将以未加密方式发送。 如何设置 + 附近的设备 + 扫描附近的设备 + 附近没有“查找”设备。AirTag 和其他 Apple“查找”设备会在此屏幕打开期间显示在这里。 + 允许蓝牙访问以查看附近的“查找”设备。 + “查找”设备 · %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 9bf37589..cfed503b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -423,4 +423,12 @@ 響鈴失敗:%1$s 不是 https:API 權杖將以未加密方式傳送。 如何設定 + 附近的裝置 + 掃描附近的裝置 + 附近沒有「尋找」裝置。AirTag 和其他 Apple「尋找」裝置會在此畫面開啟期間顯示在這裡。 + 允許藍牙存取以查看附近的「尋找」裝置。 + 「尋找」裝置 · %1$s + 約 %1$s 公尺 + 與物主分離 + 在物主附近 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f33053d..391ab99a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -458,4 +458,12 @@ If Apple simply refused the request, waiting and trying again is worth doing fir Ring failed: %1$s Not https: the API token will be sent unencrypted. How to set this up + Nearby devices + Scan nearby devices + No Find My devices in range. AirTags and other Apple Find My devices appear here while this screen is open. + Allow Bluetooth access to see nearby Find My devices. + Find My device · %1$s + ~%1$s m + separated from its owner + with its owner diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/FindMyFrameKeyTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/FindMyFrameKeyTest.java new file mode 100644 index 00000000..9138bc43 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/FindMyFrameKeyTest.java @@ -0,0 +1,123 @@ +package dev.wander.android.opentagviewer.ble; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +/** + * The key reconstruction, pinned against the layout FindMy.py's scanner.py implements. + * + *

The MAC below is chosen so its bytes differ from every payload byte - a swapped source + * shows up immediately rather than by coincidence. + */ +public class FindMyFrameKeyTest { + + private static final String MAC = "AA:BB:CC:DD:EE:FF"; + + /** + * A separated frame: {@code 0x12, 0x19, status, 22 key-tail bytes (0x01..0x16), topTwo, + * hint}. {@code topTwo = 0x02} puts 0b10 in the reconstructed first byte's top bits. + */ + private static byte[] separatedFrame() { + final byte[] frame = new byte[27]; + frame[0] = 0x12; + frame[1] = 0x19; + frame[2] = 0x10; // status + for (int i = 0; i < 22; i++) { + frame[3 + i] = (byte) (i + 1); // key tail, bytes 6..27 of the public key + } + frame[25] = 0x02; // top two bits of key byte 0 + frame[26] = 0x42; // hint + return frame; + } + + /** A nearby frame: {@code 0x12, 0x02, status, topTwo}. */ + private static byte[] nearbyFrame(final int topTwo) { + return new byte[]{0x12, 0x02, 0x10, (byte) topTwo}; + } + + @Test + public void separatedReconstructsTheWholeKey() { + final FindMyFrameKey key = FindMyFrameKey.parse(separatedFrame(), MAC); + + // key[0] = (0x02 << 6) | (0xAA & 0x3f) = 0x80 | 0x2A = 0xAA + final byte[] expected = new byte[28]; + expected[0] = (byte) 0xAA; + expected[1] = (byte) 0xBB; + expected[2] = (byte) 0xCC; + expected[3] = (byte) 0xDD; + expected[4] = (byte) 0xEE; + expected[5] = (byte) 0xFF; + for (int i = 0; i < 22; i++) { + expected[6 + i] = (byte) (i + 1); + } + + assertArrayEquals(expected, key.getFullKey()); + assertEquals(0x42, key.getHint()); + assertEquals(0x10, key.getStatusByte()); + } + + @Test + public void separatedAndNearbyYieldTheSamePrefix() { + // The same tag sending both shapes within one rotation: identical MAC, identical + // topTwo. The whole point of the prefix - this is the join the list relies on. + final FindMyFrameKey separated = FindMyFrameKey.parse(separatedFrame(), MAC); + final FindMyFrameKey nearby = FindMyFrameKey.parse(nearbyFrame(0x02), MAC); + + assertArrayEquals(separated.getKeyPrefix(), nearby.getKeyPrefix()); + assertEquals(separated.keyPrefixHex(), nearby.keyPrefixHex()); + } + + @Test + public void nearbyCarriesNoFullKey() { + assertNull(FindMyFrameKey.parse(nearbyFrame(0x02), MAC).getFullKey()); + assertNull(FindMyFrameKey.parse(nearbyFrame(0x02), MAC).fullKeyHex()); + } + + @Test + public void topBitsChangeThePrefixWhereTheMacCannot() { + // Two keys sharing a MAC differ only in bits the BLE address cannot express. The + // prefix must see them - a MAC-keyed list would merge two devices. + final FindMyFrameKey top2 = FindMyFrameKey.parse(nearbyFrame(0x02), MAC); + final FindMyFrameKey top3 = FindMyFrameKey.parse(nearbyFrame(0x03), MAC); + + assertEquals((byte) 0xAA, top2.getKeyPrefix()[0]); // 0x80 | 0x2A + assertEquals((byte) 0xEA, top3.getKeyPrefix()[0]); // 0xC0 | 0x2A + } + + @Test + public void findsTheFindMyTlvBehindAnotherAppleFrame() { + // An iPhone prepends its own payloads in the same 0x004C blob; 0x12 need not lead. + final byte[] nearby = nearbyFrame(0x02); + final byte[] blob = new byte[3 + nearby.length]; + blob[0] = 0x10; // nearby-info type + blob[1] = 0x01; + blob[2] = 0x00; + System.arraycopy(nearby, 0, blob, 3, nearby.length); + + final FindMyFrameKey key = FindMyFrameKey.parse(blob, MAC); + assertEquals("aabbccddeeff".substring(0, 4), key.keyPrefixHex().substring(0, 4)); + } + + @Test + public void rejectsWhatIsNotFindMy() { + assertNull(FindMyFrameKey.parse(null, MAC)); + assertNull(FindMyFrameKey.parse(new byte[]{0x12}, MAC)); + assertNull(FindMyFrameKey.parse(new byte[]{0x10, 0x02, 0x00, 0x00}, MAC)); + // A 0x12 of a length that is neither known shape. + assertNull(FindMyFrameKey.parse(new byte[]{0x12, 0x05, 0, 0, 0, 0, 0}, MAC)); + // A truncated TLV at the tail. + assertNull(FindMyFrameKey.parse(new byte[]{0x10, 0x01, 0x00, 0x12, 0x19}, MAC)); + // And a MAC that is not one. + assertNull(FindMyFrameKey.parse(nearbyFrame(0x02), "not-a-mac")); + assertNull(FindMyFrameKey.parse(nearbyFrame(0x02), null)); + } + + @Test + public void hexRendersLowercase() { + assertEquals("aa" + "bbccddeeff", + FindMyFrameKey.parse(nearbyFrame(0x02), MAC).keyPrefixHex()); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBookTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBookTest.java new file mode 100644 index 00000000..8672a99b --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBookTest.java @@ -0,0 +1,73 @@ +package dev.wander.android.opentagviewer.ble; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import dev.wander.android.opentagviewer.ble.FindMyAdvertisement.BatteryLevel; +import dev.wander.android.opentagviewer.ble.FindMyAdvertisement.State; + +import java.util.List; + +/** + * The aggregation rules of {@link NearbyDeviceBook}: newest per prefix, pruned when silent, + * sorted for the list. + */ +public class NearbyDeviceBookTest { + + private static NearbyDeviceSighting sighting( + final String prefix, final int rssi, final long seenAtMs) { + return NearbyDeviceSighting.builder() + .mac("AA:BB:CC:DD:EE:FF") + .keyPrefix(prefix) + .rssi(rssi) + .batteryLevel(BatteryLevel.FULL) + .statusByte(0x10) + .state(State.SEPARATED) + .seenAtMs(seenAtMs) + .build(); + } + + @Test + public void keepsOneRowPerPrefixHoldingTheNewestSighting() { + final NearbyDeviceBook book = new NearbyDeviceBook(); + book.hear(sighting("aabbccddeeff", -80, 1_000)); + book.hear(sighting("aabbccddeeff", -60, 2_000)); + + final List rows = book.snapshot(2_000); + assertEquals(1, rows.size()); + assertEquals(-60, rows.get(0).getRssi()); + } + + @Test + public void ignoresAStaleResendOfTheSamePrefix() { + // An out-of-order delivery - e.g. a batched callback - must not resurrect an older + // RSSI over the newest one. + final NearbyDeviceBook book = new NearbyDeviceBook(); + book.hear(sighting("aabbccddeeff", -60, 2_000)); + book.hear(sighting("aabbccddeeff", -95, 1_000)); + + assertEquals(-60, book.snapshot(2_000).get(0).getRssi()); + } + + @Test + public void sortsStrongestSignalFirst() { + final NearbyDeviceBook book = new NearbyDeviceBook(); + book.hear(sighting("111111111111", -80, 1_000)); + book.hear(sighting("222222222222", -50, 1_000)); + + assertEquals("222222222222", book.snapshot(1_000).get(0).getKeyPrefix()); + } + + @Test + public void prunesAPrefixThatWentQuiet() { + // Departure sends nothing: silence is the only signal. A tag unheard past + // GONE_AFTER_MS must fall out of the list rather than read as still present. + final NearbyDeviceBook book = new NearbyDeviceBook(); + book.hear(sighting("aabbccddeeff", -60, 1_000)); + + assertEquals(1, book.snapshot(30_999).size()); + assertTrue(book.snapshot(31_001).isEmpty()); + } +} From 42db8400fe371122b2e05094c78d11e96bd02e52 Mon Sep 17 00:00:00 2001 From: Ruben Barkow-Kuder Date: Wed, 14 Oct 2026 23:32:06 +0200 Subject: [PATCH 2/3] Fix what the Copilot review found in the generic scanner - The hardware filter and the sighting parser both demanded the Find My record at manufacturer-data offset zero, but Apple's TLV chain does not guarantee it: Nearby Info and Handoff records routinely lead. The scanner now filters on the company ID alone and sorts the chain in software (NearbyDeviceSighting reuses FindMyFrameKey.findFindMyTlv), so a 0x12 behind another record still becomes a row. A new NearbyDeviceSightingTest pins that with the prefixed fixture. - Bluetooth going off or a refused scan completed the subscription silently, and the refresh timer then overwrote the reason with "no devices in range". The screen now resubscribes on a 30-second retry - the same cadence MapsActivity.onNearbyWatchEnded uses - and the empty state distinguishes "Bluetooth is off or unavailable" from "nothing heard" via a new string in all locales. - Removed the manual onResume() after the permission grant: the dialog pauses the activity, so the framework resumes it anyway, and the extra call subscribed a second scan whose disposable was then overwritten - onPause could only stop the newer pair. - The MAC row used colorOutline at 12sp, 4.25:1 against the row background; colorOnSurfaceVariant clears the 4.5:1 normal-text floor. Co-Authored-By: Devin/SWE-2 <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../opentagviewer/NearbyDevicesActivity.java | 70 +++++++++++-- .../opentagviewer/ble/FindMyFrameKey.java | 2 +- .../ble/NearbyDeviceScanner.java | 24 +++-- .../ble/NearbyDeviceSighting.java | 17 ++-- .../res/layout/nearby_device_list_item.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 + .../ble/NearbyDeviceSightingTest.java | 97 +++++++++++++++++++ 16 files changed, 193 insertions(+), 29 deletions(-) create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDeviceSightingTest.java diff --git a/app/src/main/java/dev/wander/android/opentagviewer/NearbyDevicesActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/NearbyDevicesActivity.java index 39e44ed7..a4e52f39 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/NearbyDevicesActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/NearbyDevicesActivity.java @@ -65,6 +65,9 @@ public class NearbyDevicesActivity extends AppCompatActivity { */ private static final long REFRESH_INTERVAL_MS = TimeUnit.SECONDS.toMillis(1); + /** Resubscribe interval after the scan ended mid-session; mirrors MapsActivity's choice. */ + private static final long SCAN_RETRY_DELAY_MS = TimeUnit.SECONDS.toMillis(30); + private final NearbyDeviceScanner scanner = new NearbyDeviceScanner(); private final NearbyDeviceBook book = new NearbyDeviceBook(); private ForeignTagNames names; @@ -75,6 +78,19 @@ public class NearbyDevicesActivity extends AppCompatActivity { private Disposable scanSubscription; private Disposable refreshSubscription; + /** + * The pending resubscribe after the scan ended mid-session - Bluetooth toggled off or a + * failed scan both arrive as a quiet {@code onComplete}, so without this the screen keeps + * claiming "nothing in range" forever while showing a radio nobody listens on. + */ + private Disposable scanRetrySubscription; + + /** + * True between the scan completing mid-session and a resubscribe that stayed alive. The + * empty state reads from it: a dead radio needs "turn Bluetooth on", not "no devices". + */ + private boolean scanUnavailable; + /** Asked at most once per screen, for the same reason MapsActivity bounds its own request. */ private boolean blePermissionRequested = false; @@ -118,11 +134,7 @@ protected void onResume() { return; } - this.scanSubscription = this.scanner.scan(this.getApplicationContext()) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onSighting, - error -> Log.e(TAG, "Nearby device scan failed", error)); + this.startScanning(); this.refreshSubscription = Observable .interval(REFRESH_INTERVAL_MS, TimeUnit.MILLISECONDS, Schedulers.computation()) @@ -141,6 +153,10 @@ protected void onPause() { this.refreshSubscription.dispose(); this.refreshSubscription = null; } + if (this.scanRetrySubscription != null) { + this.scanRetrySubscription.dispose(); + this.scanRetrySubscription = null; + } super.onPause(); } @@ -153,13 +169,45 @@ public void onRequestPermissionsResult( if (requestCode != NEARBY_PERMISSION_REQUEST_CODE) { return; } - // Granted: the next onResume starts the scan. Denied: the empty state already explains - // what is missing - re-prompting here would loop. - if (BlePermissions.granted(this)) { - this.onResume(); + // The permission dialog pauses the activity, so the framework's own onResume after it + // starts the scan. Calling onResume here would subscribe twice - onPause then disposes + // only the second pair and leaves a scan running on a closed screen. + if (!BlePermissions.granted(this)) { + this.showEmptyState(R.string.nearby_devices_needs_permission); } } + private void startScanning() { + this.scanUnavailable = false; + this.scanSubscription = this.scanner.scan(this.getApplicationContext()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::onSighting, + error -> Log.e(TAG, "Nearby device scan failed", error), + this::onScanEnded); + } + + /** + * The scan completed while the screen was open: Bluetooth went off, or the platform + * refused the scan. Retry like {@code MapsActivity#onNearbyWatchEnded} - one attempt per + * 30 seconds stays far under the scan-start budget and self-heals when the radio returns. + */ + private void onScanEnded() { + this.scanSubscription = null; + this.scanUnavailable = true; + this.rebuildList(); + + Log.i(TAG, "The nearby device scan ended mid-session; retrying in 30s"); + this.scanRetrySubscription = Observable + .timer(SCAN_RETRY_DELAY_MS, TimeUnit.MILLISECONDS, + AndroidSchedulers.mainThread()) + .subscribe(tick -> { + if (BlePermissions.granted(this)) { + this.startScanning(); + } + }); + } + private void onSighting(final NearbyDeviceSighting sighting) { this.book.hear(sighting); this.rebuildList(); @@ -170,7 +218,9 @@ private void rebuildList() { this.book.snapshot(System.currentTimeMillis()); if (rows.isEmpty()) { - this.showEmptyState(R.string.nearby_devices_empty); + this.showEmptyState(this.scanUnavailable + ? R.string.nearby_devices_scan_unavailable + : R.string.nearby_devices_empty); return; } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyFrameKey.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyFrameKey.java index 8da33c59..a25ba58a 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyFrameKey.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyFrameKey.java @@ -136,7 +136,7 @@ public static FindMyFrameKey parse( * safe only if the caller checked {@code +2 + len <= data.length} itself - which is * why this also bounds-checks each TLV before returning it. */ - private static int findFindMyTlv(@Nullable final byte[] data) { + static int findFindMyTlv(@Nullable final byte[] data) { if (data == null) { return -1; } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceScanner.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceScanner.java index 83923681..733dbd1f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceScanner.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceScanner.java @@ -28,10 +28,12 @@ * radio traffic and throws most of it away - anything whose address is not in the user's own * tag index - because answering "is this mine" is its whole job. This class answers the other * question, "what is out there at all", so it emits every frame that parses as a Find My - * advertisement. It shares the watcher's scan shape deliberately: same hardware filter (the - * controller discards non-Find-My frames so the callback only fires for ours), same restart - * before the platform's long-scan downgrade, same contract of completing rather than erroring - * when Bluetooth or permission is missing. + * advertisement. It shares the watcher's scan shape deliberately apart from the filter: the + * watcher can hardware-filter on the 0x12 record at offset zero because its own tags always + * lead with it, while this scanner filters on Apple's company ID alone and sorts the TLV + * chain in software - a Find My frame behind another Apple record must still be seen. Same + * restart before the platform's long-scan downgrade, same contract of completing rather than + * erroring when Bluetooth or permission is missing. * *

What it does not share is anything expensive. No index, no MAC resolver, no Python - * a frame goes from the radio to the emitter as pure byte parsing, which is also why this can @@ -113,13 +115,15 @@ public void onScanFailed(final int errorCode) { } }; - // Filtered in hardware for the same reason the watcher filters: the controller - // drops every non-Find-My frame itself, so an ordinary flat's dozens of stray BLE - // advertisements never reach this callback at all. + // Company ID only, no data mask: a mask can only match a fixed offset, but the + // Find My TLV does not have to lead Apple's TLV chain - Nearby Info and Handoff + // records routinely sit in front of it. Software parsing takes over the 0x12 + // check in sightingFrom, so frames a mask cannot express still reach the list. + // The trade is callback traffic: every Apple advertisement lands here, including + // AirPods chatter - acceptable while a screen is open, wrong for a background + // service. final List findMyFramesOnly = List.of(new ScanFilter.Builder() - .setManufacturerData(FindMyAdvertisement.APPLE_COMPANY_ID, - new byte[]{FindMyAdvertisement.TYPE_OFFLINE_FINDING}, - new byte[]{(byte) 0xFF}) + .setManufacturerData(FindMyAdvertisement.APPLE_COMPANY_ID, new byte[0]) .build()); scanner.startScan(findMyFramesOnly, settingsFor(this.scanMode), callback); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceSighting.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceSighting.java index 967fa0f3..676bf5c6 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceSighting.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceSighting.java @@ -2,6 +2,8 @@ import androidx.annotation.Nullable; +import java.util.Arrays; + import lombok.Builder; import lombok.Getter; @@ -91,18 +93,19 @@ static NearbyDeviceSighting fromAdvertisement( } /** - * The Find My TLV alone, or null when the blob does not start with one. + * The Find My TLV alone, wherever it sits in the blob, or null when the chain has none. * - *

{@link FindMyAdvertisement#parse} reads offset zero, so it is handed the whole blob as - * long as the Find My frame leads it - which {@link FindMyFrameKey#parse} already verified - * by the time this runs on the same data. Frames where 0x12 is not first fall out the same - * {@code null} both checks would produce anyway. + *

{@link FindMyAdvertisement#parse} reads offset zero, so it is handed just the record + * {@link FindMyFrameKey#findFindMyTlv} located - the same walk the key parser does. Apple + * devices routinely lead the blob with other TLVs (Nearby Info, Handoff) before the Find My + * frame; rejecting those would silently drop a real share of beacons. */ @Nullable private static byte[] tlvBytes(@Nullable final byte[] data) { - if (data == null || data.length < 3 || data[0] != FindMyFrameKey.TYPE_OFFLINE_FINDING) { + final int start = FindMyFrameKey.findFindMyTlv(data); + if (start < 0) { return null; } - return data; + return Arrays.copyOfRange(data, start, start + 2 + (data[start + 1] & 0xFF)); } } diff --git a/app/src/main/res/layout/nearby_device_list_item.xml b/app/src/main/res/layout/nearby_device_list_item.xml index 20b04511..fff30aca 100644 --- a/app/src/main/res/layout/nearby_device_list_item.xml +++ b/app/src/main/res/layout/nearby_device_list_item.xml @@ -52,7 +52,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:fontFamily="monospace" - android:textColor="?attr/colorOutline" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="12sp" tools:text="AA:BB:CC:DD:EE:FF" /> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 22564136..f0a495a2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -431,4 +431,5 @@ Wenn Apple die Anfrage lediglich abgelehnt hat, lohnt es sich, zu warten und es ~%1$s m vom Besitzer getrennt beim Besitzer + Bluetooth ist aus oder nicht verfügbar. Einschalten, um Geräte in der Nähe zu sehen — es wird automatisch erneut versucht. \ 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 b1017761..c4c5a41a 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -431,4 +431,5 @@ If Apple simply refused the request, waiting and trying again is worth doing fir ~%1$s m separated from its owner with its owner + Bluetooth is off or unavailable. Turn it on to see nearby Find My devices — retrying automatically. \ 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 e7ba2786..f206e61a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -431,4 +431,5 @@ Si Apple a simplement refusé la requête, il vaut mieux attendre et réessayer. ~%1$s m séparé de son propriétaire avec son propriétaire + Bluetooth désactivé ou indisponible. Activez-le pour voir les appareils Find My à proximité — nouvel essai automatique. \ 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 83ad7180..c5285c55 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -431,4 +431,5 @@ Appleが要求を拒否しただけなら、少し待ってからもう一度お 約 %1$s m 持ち主と離れています 持ち主の近く + Bluetooth がオフか利用できません。オンにすると近くの Find My デバイスが表示されます — 自動で再試行します。 \ 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 267faac0..d05d496a 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -431,4 +431,5 @@ Apple이 요청을 거부한 것뿐이라면 잠시 기다렸다가 다시 시 약 %1$s m 소유자와 떨어짐 소유자와 함께 있음 + Bluetooth가 꺼져 있거나 사용할 수 없습니다. 켜면 근처 Find My 기기를 볼 수 있습니다 — 자동으로 다시 시도합니다. \ 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 47a8e089..4c54ed20 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -431,4 +431,5 @@ Als Apple het verzoek simpelweg weigerde, is even wachten en opnieuw proberen he ~%1$s m gescheiden van de eigenaar bij de eigenaar + Bluetooth staat uit of is niet beschikbaar. Zet het aan om Find My-apparaten in de buurt te zien — automatisch opnieuw proberen. \ 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 6dba4209..8b2b19f8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -431,4 +431,5 @@ ~%1$s м отделено от владельца рядом с владельцем + Bluetooth выключен или недоступен. Включите его, чтобы видеть устройства Find My рядом — повторная попытка автоматически. \ 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 87c997a7..38412450 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -431,4 +431,5 @@ 约 %1$s 米 与物主分离 在物主附近 + 蓝牙已关闭或不可用。打开后可查看附近的 Find My 设备 — 将自动重试。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index cfed503b..568c2323 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -431,4 +431,5 @@ 約 %1$s 公尺 與物主分離 在物主附近 + 藍牙已關閉或無法使用。開啟後可查看附近的 Find My 裝置 — 將自動重試。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 391ab99a..2674912a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -466,4 +466,5 @@ If Apple simply refused the request, waiting and trying again is worth doing fir ~%1$s m separated from its owner with its owner + Bluetooth is off or unavailable. Turn it on to see nearby Find My devices — retrying automatically. diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDeviceSightingTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDeviceSightingTest.java new file mode 100644 index 00000000..0588f89a --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDeviceSightingTest.java @@ -0,0 +1,97 @@ +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 org.junit.Test; + +/** + * Sighting assembly out of a manufacturer-data blob - mostly "the Find My TLV is found where + * it sits". + * + *

Apple manufacturer data is a TLV chain and the 0x12 record does not have to lead it: + * Nearby Info and Handoff records routinely sit in front. A scanner that only understands the + * leading position silently drops every such device. + */ +public class NearbyDeviceSightingTest { + + private static final String MAC = "AA:BB:CC:DD:EE:FF"; + + /** Same separated frame {@link FindMyFrameKeyTest} reconstructs, so the expected key matches. */ + private static byte[] separatedFrame() { + final byte[] frame = new byte[27]; + frame[0] = 0x12; + frame[1] = 0x19; + frame[2] = 0x10; // status + for (int i = 0; i < 22; i++) { + frame[3 + i] = (byte) (i + 1); + } + frame[25] = 0x02; // top two bits of key byte 0 + frame[26] = 0x42; // hint + return frame; + } + + /** A nearby frame: {@code 0x12, 0x02, status, topTwo}. */ + private static byte[] nearbyFrame() { + return new byte[]{0x12, 0x02, 0x10, 0x01}; + } + + /** A Nearby Info record ({@code 0x10, len 1}) concatenated in front of the frame. */ + private static byte[] prefixed(final byte[] frame) { + final byte[] blob = new byte[3 + frame.length]; + blob[0] = 0x10; + blob[1] = 0x01; + blob[2] = 0x00; + System.arraycopy(frame, 0, blob, 3, frame.length); + return blob; + } + + @Test + public void separatedFrameAtOffsetZeroParses() { + final NearbyDeviceSighting sighting = NearbyDeviceSighting.fromAdvertisement( + MAC, separatedFrame(), -60, 1000L); + + assertNotNull(sighting); + assertEquals("aabbccddeeff", sighting.getKeyPrefix()); + assertNotNull(sighting.getFullKey()); + assertEquals(-60, sighting.getRssi()); + assertEquals(1000L, sighting.getSeenAtMs()); + assertEquals(0x10, sighting.getStatusByte()); + assertEquals(0x42, sighting.getHint()); + } + + @Test + public void separatedFrameBehindAnotherTlvStillParses() { + final NearbyDeviceSighting sighting = NearbyDeviceSighting.fromAdvertisement( + MAC, prefixed(separatedFrame()), -60, 1000L); + + assertNotNull(sighting); + assertEquals("aabbccddeeff", sighting.getKeyPrefix()); + assertNotNull(sighting.getFullKey()); + } + + @Test + public void nearbyFrameBehindAnotherTlvStillParses() { + final NearbyDeviceSighting sighting = NearbyDeviceSighting.fromAdvertisement( + MAC, prefixed(nearbyFrame()), -60, 1000L); + + assertNotNull(sighting); + // key[0] = (0x01 << 6) | (0xAA & 0x3f) = 0x6A; the MAC supplies bytes 1..5. + assertEquals("6abbccddeeff", sighting.getKeyPrefix()); + assertNull(sighting.getFullKey()); + } + + @Test + public void blobWithoutFindMyRecordYieldsNoSighting() { + assertNull(NearbyDeviceSighting.fromAdvertisement( + MAC, new byte[]{0x10, 0x01, 0x00, 0x0C, 0x02, 0x01, 0x02}, -60, 1000L)); + } + + @Test + public void truncatedRecordYieldsNoSighting() { + // Advertised length runs past the blob's end. + assertNull(NearbyDeviceSighting.fromAdvertisement( + MAC, new byte[]{0x10, 0x01, 0x00, 0x12, 0x19, 0x10}, -60, 1000L)); + } +} From c6326d2b319f34e36c7ad25cb77bca807388593a Mon Sep 17 00:00:00 2001 From: Ruben Barkow-Kuder Date: Thu, 15 Oct 2026 09:01:58 +0200 Subject: [PATCH 3/3] Keep departed devices on screen as dimmed offline rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NearbyDeviceBook: silence past GONE_AFTER_MS now marks a row offline instead of dropping it; snapshot returns live rows by RSSI first, then offline rows by most-recently-heard, and only forgets a prefix past RETAIN_GONE_MS (10 min) so a busy day cannot fill the list forever. - The adaptor draws offline rows at half alpha and reports only "offline · " - distance, battery and owner-state from the last hearing would read as current conditions. - NearbyDeviceBookTest: offline retention, horizon pruning, and the live-before-offline ordering. - nearby_device_offline in all ten locales. Co-Authored-By: Devin/SWE-2 <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../opentagviewer/ble/NearbyDeviceBook.java | 45 ++++++++++++++----- .../NearbyDeviceListAdaptor.java | 34 ++++++++++---- 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 + .../ble/NearbyDeviceBookTest.java | 43 ++++++++++++++++-- 13 files changed, 109 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBook.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBook.java index 69d4684d..cb21b5c2 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBook.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBook.java @@ -24,15 +24,24 @@ public final class NearbyDeviceBook { /** - * How long a prefix may go unheard before its row counts as gone. + * How long a prefix may go unheard before its row counts as offline. * *

Deliberately shorter than any rotation: a vanished prefix is almost always out of - * range or quieted, and holding it for 15 minutes would list departed tags as present. - * Rows that cross this are pruned by {@link #snapshot}, not by the scan - silence is the - * only signal disappearance has. + * range or quieted, and holding it live for 15 minutes would list departed tags as + * present. Silence is the only signal disappearance has, so staleness is judged by this + * threshold rather than by anything the scan reports. */ public static final long GONE_AFTER_MS = 30_000; + /** + * How long an unheard row stays on screen before the book forgets it. + * + *

Boundless retention would fill the list with every tag ever heard on a long day, + * so the book only bridges the gap a user actually notices. Ten minutes covers "walked + * to the car and came back" without immortalising a commute. + */ + public static final long RETAIN_GONE_MS = 10 * 60_000; + private final Map latestByPrefix = new LinkedHashMap<>(); /** Records a sighting under its prefix; the newest one wins the row. */ @@ -44,17 +53,31 @@ public void hear(final NearbyDeviceSighting sighting) { } /** - * The current rows, strongest signal first. + * The current rows: live devices by signal strength first, then offline devices by + * most-recently-heard. * - *

Prunes prefixes unheard for {@link #GONE_AFTER_MS} first: a tag that left should fall - * out of the list, not linger with a stale RSSI implying it is still here. + *

Prunes prefixes unheard for {@link #RETAIN_GONE_MS} first; rows past + * {@link #GONE_AFTER_MS} are kept and reported via {@link #isGone} so the list can draw + * them dimmed instead of dropping them - a tag that walked out of range is still + * interesting for a while. */ public List snapshot(final long nowMs) { - this.latestByPrefix.values().removeIf(s -> nowMs - s.getSeenAtMs() > GONE_AFTER_MS); + this.latestByPrefix.values().removeIf(s -> nowMs - s.getSeenAtMs() > RETAIN_GONE_MS); + + final List live = new ArrayList<>(); + final List gone = new ArrayList<>(); + for (final NearbyDeviceSighting sighting : this.latestByPrefix.values()) { + (isGone(sighting, nowMs) ? gone : live).add(sighting); + } + live.sort(Comparator.comparingInt(NearbyDeviceSighting::getRssi).reversed()); + gone.sort(Comparator.comparingLong(NearbyDeviceSighting::getSeenAtMs).reversed()); + live.addAll(gone); + return live; + } - final List rows = new ArrayList<>(this.latestByPrefix.values()); - rows.sort(Comparator.comparingInt(NearbyDeviceSighting::getRssi).reversed()); - return rows; + /** Whether a sighting is stale enough to draw as offline rather than live. */ + public static boolean isGone(final NearbyDeviceSighting sighting, final long nowMs) { + return nowMs - sighting.getSeenAtMs() > GONE_AFTER_MS; } /** The newest sighting for one prefix, or null. */ diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/nearbydevices/NearbyDeviceListAdaptor.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/nearbydevices/NearbyDeviceListAdaptor.java index b2389842..d513e9a9 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/nearbydevices/NearbyDeviceListAdaptor.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/nearbydevices/NearbyDeviceListAdaptor.java @@ -18,6 +18,7 @@ import dev.wander.android.opentagviewer.R; import dev.wander.android.opentagviewer.ble.FindMyAdvertisement; +import dev.wander.android.opentagviewer.ble.NearbyDeviceBook; import dev.wander.android.opentagviewer.ble.NearbyDeviceSighting; import dev.wander.android.opentagviewer.util.nearby.ProximityDistance; import lombok.Getter; @@ -33,6 +34,13 @@ public class NearbyDeviceListAdaptor extends RecyclerView.Adapter { + /** + * Opacity for a row whose device stopped advertising: dimmed but still readable - + * {@code colorControlHighlight}-like greys would fail contrast, so the whole row + * including icon and text fades to roughly half instead. + */ + private static final float GONE_ROW_ALPHA = 0.5f; + /** Click: select the device (future radar). Long click: rename - carries the row view as anchor. */ private final Consumer onClickCallback; private final BiConsumer onLongClickCallback; @@ -88,6 +96,7 @@ public ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int @Override public void onBindViewHolder(@NonNull final ViewHolder holder, final int position) { final NearbyDeviceSighting sighting = this.rows.get(position); + final boolean gone = NearbyDeviceBook.isGone(sighting, System.currentTimeMillis()); final String givenName = this.nameResolver.apply(sighting.getKeyPrefix()); final String title = givenName != null @@ -96,15 +105,21 @@ public void onBindViewHolder(@NonNull final ViewHolder holder, final int positio sighting.getKeyPrefix().substring(0, 4)); holder.getDeviceName().setText(title); - // The details line is assembled from localized pieces: distance, battery, owner-state, - // then how stale the newest hearing is. final List parts = new ArrayList<>(); - parts.add(this.resources.getString(R.string.nearby_device_distance_format, - ProximityDistance.describe(sighting.getRssi()))); - parts.add(this.resources.getString(batteryString(sighting.getBatteryLevel()))); - parts.add(this.resources.getString(sighting.getState() == FindMyAdvertisement.State.SEPARATED - ? R.string.nearby_device_separated - : R.string.nearby_device_with_owner)); + if (gone) { + // Distance, battery and owner-state would claim conditions from before the tag + // left; the row reports only that it went quiet and when it was last heard. + parts.add(this.resources.getString(R.string.nearby_device_offline)); + } else { + // The details line is assembled from localized pieces: distance, battery, + // owner-state, then how stale the newest hearing is. + parts.add(this.resources.getString(R.string.nearby_device_distance_format, + ProximityDistance.describe(sighting.getRssi()))); + parts.add(this.resources.getString(batteryString(sighting.getBatteryLevel()))); + parts.add(this.resources.getString(sighting.getState() == FindMyAdvertisement.State.SEPARATED + ? R.string.nearby_device_separated + : R.string.nearby_device_with_owner)); + } parts.add(DateUtils.getRelativeTimeSpanString( sighting.getSeenAtMs(), System.currentTimeMillis(), DateUtils.SECOND_IN_MILLIS).toString()); @@ -112,6 +127,9 @@ public void onBindViewHolder(@NonNull final ViewHolder holder, final int positio holder.getMac().setText(sighting.getMac()); + // Rows are recycled, so the dimming has to be set unconditionally on every bind. + holder.getContainer().setAlpha(gone ? GONE_ROW_ALPHA : 1.0f); + holder.getContainer().setOnClickListener(v -> this.onClickCallback.accept(sighting)); holder.getContainer().setOnLongClickListener(v -> { this.onLongClickCallback.accept(v, sighting); diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index f0a495a2..a5d9781a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -432,4 +432,5 @@ Wenn Apple die Anfrage lediglich abgelehnt hat, lohnt es sich, zu warten und es vom Besitzer getrennt beim Besitzer Bluetooth ist aus oder nicht verfügbar. Einschalten, um Geräte in der Nähe zu sehen — es wird automatisch erneut versucht. + offline \ 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 c4c5a41a..6ae46d68 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -432,4 +432,5 @@ If Apple simply refused the request, waiting and trying again is worth doing fir separated from its owner with its owner Bluetooth is off or unavailable. Turn it on to see nearby Find My devices — retrying automatically. + offline \ 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 f206e61a..7375fae6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -432,4 +432,5 @@ Si Apple a simplement refusé la requête, il vaut mieux attendre et réessayer. séparé de son propriétaire avec son propriétaire Bluetooth désactivé ou indisponible. Activez-le pour voir les appareils Find My à proximité — nouvel essai automatique. + hors ligne \ 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 c5285c55..35927b1a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -432,4 +432,5 @@ Appleが要求を拒否しただけなら、少し待ってからもう一度お 持ち主と離れています 持ち主の近く Bluetooth がオフか利用できません。オンにすると近くの Find My デバイスが表示されます — 自動で再試行します。 + オフライン \ 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 d05d496a..150b5e25 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -432,4 +432,5 @@ Apple이 요청을 거부한 것뿐이라면 잠시 기다렸다가 다시 시 소유자와 떨어짐 소유자와 함께 있음 Bluetooth가 꺼져 있거나 사용할 수 없습니다. 켜면 근처 Find My 기기를 볼 수 있습니다 — 자동으로 다시 시도합니다. + 오프라인 \ 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 4c54ed20..d5b553e0 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -432,4 +432,5 @@ Als Apple het verzoek simpelweg weigerde, is even wachten en opnieuw proberen he gescheiden van de eigenaar bij de eigenaar Bluetooth staat uit of is niet beschikbaar. Zet het aan om Find My-apparaten in de buurt te zien — automatisch opnieuw proberen. + offline \ 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 8b2b19f8..2350a691 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -432,4 +432,5 @@ отделено от владельца рядом с владельцем Bluetooth выключен или недоступен. Включите его, чтобы видеть устройства Find My рядом — повторная попытка автоматически. + не в сети \ 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 38412450..a646404b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -432,4 +432,5 @@ 与物主分离 在物主附近 蓝牙已关闭或不可用。打开后可查看附近的 Find My 设备 — 将自动重试。 + 离线 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 568c2323..564e0edb 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -432,4 +432,5 @@ 與物主分離 在物主附近 藍牙已關閉或無法使用。開啟後可查看附近的 Find My 裝置 — 將自動重試。 + 離線 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2674912a..f53f7cd2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -467,4 +467,5 @@ If Apple simply refused the request, waiting and trying again is worth doing fir separated from its owner with its owner Bluetooth is off or unavailable. Turn it on to see nearby Find My devices — retrying automatically. + offline diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBookTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBookTest.java index 8672a99b..c2fa01fa 100644 --- a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBookTest.java +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBookTest.java @@ -1,6 +1,7 @@ package dev.wander.android.opentagviewer.ble; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import org.junit.Test; @@ -61,13 +62,47 @@ public void sortsStrongestSignalFirst() { } @Test - public void prunesAPrefixThatWentQuiet() { + public void keepsAGonePrefixAsAnOfflineRow() { // Departure sends nothing: silence is the only signal. A tag unheard past - // GONE_AFTER_MS must fall out of the list rather than read as still present. + // GONE_AFTER_MS stays listed as offline rather than vanishing mid-glance. final NearbyDeviceBook book = new NearbyDeviceBook(); book.hear(sighting("aabbccddeeff", -60, 1_000)); - assertEquals(1, book.snapshot(30_999).size()); - assertTrue(book.snapshot(31_001).isEmpty()); + assertFalse(NearbyDeviceBook.isGone(book.snapshot(30_999).get(0), 30_999)); + + final List rows = book.snapshot(31_001); + assertEquals(1, rows.size()); + assertTrue(NearbyDeviceBook.isGone(rows.get(0), 31_001)); + } + + @Test + public void prunesAPrefixUnheardPastTheRetentionHorizon() { + final NearbyDeviceBook book = new NearbyDeviceBook(); + book.hear(sighting("aabbccddeeff", -60, 1_000)); + + assertEquals(1, book.snapshot(1_000 + NearbyDeviceBook.RETAIN_GONE_MS).size()); + assertTrue(book.snapshot(1_001 + NearbyDeviceBook.RETAIN_GONE_MS).isEmpty()); + } + + @Test + public void listsLiveRowsBeforeOfflineRows() { + final NearbyDeviceBook book = new NearbyDeviceBook(); + book.hear(sighting("aabbccddeeff", -95, 1_000)); // strong-ranking but long gone + book.hear(sighting("111111111111", -50, 60_000)); // faint but live + + final List rows = book.snapshot(60_000); + assertEquals("111111111111", rows.get(0).getKeyPrefix()); + assertEquals("aabbccddeeff", rows.get(1).getKeyPrefix()); + } + + @Test + public void sortsOfflineRowsByMostRecentlyHeard() { + final NearbyDeviceBook book = new NearbyDeviceBook(); + book.hear(sighting("aabbccddeeff", -60, 1_000)); + book.hear(sighting("111111111111", -60, 10_000)); + + final List rows = book.snapshot(60_000); + assertEquals("111111111111", rows.get(0).getKeyPrefix()); + assertEquals("aabbccddeeff", rows.get(1).getKeyPrefix()); } }