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);
+
+ /** 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;
+
+ private ActivityNearbyDevicesBinding binding;
+ private NearbyDeviceListAdaptor adaptor;
+
+ 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;
+
+ @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.startScanning();
+
+ 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;
+ }
+ if (this.scanRetrySubscription != null) {
+ this.scanRetrySubscription.dispose();
+ this.scanRetrySubscription = 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;
+ }
+ // 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();
+ }
+
+ private void rebuildList() {
+ final List 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:
+ *
+ * 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.
+ */
+ 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 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 offline.
+ *
+ * Deliberately shorter than any rotation: a vanished prefix is almost always out of
+ * 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 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 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 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
+ * 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 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..676bf5c6
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceSighting.java
@@ -0,0 +1,111 @@
+package dev.wander.android.opentagviewer.ble;
+
+import androidx.annotation.Nullable;
+
+import java.util.Arrays;
+
+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, wherever it sits in the blob, or null when the chain has none.
+ *
+ * {@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) {
+ final int start = FindMyFrameKey.findFindMyTlv(data);
+ if (start < 0) {
+ return null;
+ }
+ return Arrays.copyOfRange(data, start, start + 2 + (data[start + 1] & 0xFF));
+ }
+}
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..d513e9a9
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/nearbydevices/NearbyDeviceListAdaptor.java
@@ -0,0 +1,154 @@
+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.NearbyDeviceBook;
+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 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 @@
+
+ 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..c2fa01fa
--- /dev/null
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBookTest.java
@@ -0,0 +1,108 @@
+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;
+
+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 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));
+ }
+}
+ *
+ *
+ *