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); + + /** 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 rows = + this.book.snapshot(System.currentTimeMillis()); + + if (rows.isEmpty()) { + this.showEmptyState(this.scanUnavailable + ? R.string.nearby_devices_scan_unavailable + : 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..a25ba58a --- /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: + * + *

+ * + *

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 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..cb21b5c2 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceBook.java @@ -0,0 +1,88 @@ +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 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 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: live devices by signal strength first, then offline devices by + * most-recently-heard. + * + *

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() > 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; + } + + /** 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. */ + @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..733dbd1f --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyDeviceScanner.java @@ -0,0 +1,191 @@ +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 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 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(); + } + } + }; + + // 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[0]) + .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..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 { + + /** + * 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; + + /** 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 boolean gone = NearbyDeviceBook.isGone(sighting, System.currentTimeMillis()); + + 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); + + final List parts = new ArrayList<>(); + 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()); + holder.getDetails().setText(String.join(" · ", parts)); + + 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); + 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..fff30aca --- /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..a5d9781a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -423,4 +423,14 @@ 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 + 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 9bd596cf..6ae46d68 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -423,4 +423,14 @@ 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 + 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 86ce02ed..7375fae6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -423,4 +423,14 @@ 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 + 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 89f8366e..35927b1a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -423,4 +423,14 @@ Appleが要求を拒否しただけなら、少し待ってからもう一度お 鳴らせませんでした: %1$s https ではありません。API トークンは暗号化されずに送信されます。 設定方法 + 近くのデバイス + 近くのデバイスをスキャン + 範囲内に「探す」デバイスがありません。AirTag やその他の Apple「探す」対応デバイスは、この画面を開いている間ここに表示されます。 + 近くの「探す」デバイスを表示するには Bluetooth へのアクセスを許可してください。 + 「探す」デバイス · %1$s + 約 %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 868a1313..150b5e25 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -423,4 +423,14 @@ Apple이 요청을 거부한 것뿐이라면 잠시 기다렸다가 다시 시 소리 재생 실패: %1$s https가 아닙니다. API 토큰이 암호화되지 않은 채 전송됩니다. 설정 방법 + 근처 기기 + 근처 기기 검색 + 범위 내에 \'나의 찾기\' 기기가 없습니다. AirTag 및 기타 Apple \'나의 찾기\' 기기는 이 화면이 열려 있는 동안 여기에 표시됩니다. + 근처 \'나의 찾기\' 기기를 보려면 블루투스 접근을 허용하세요. + \'나의 찾기\' 기기 · %1$s + 약 %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 d2e116d2..d5b553e0 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -423,4 +423,14 @@ 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 + 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 96a992cb..2350a691 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -423,4 +423,14 @@ Не удалось подать сигнал: %1$s Не https: токен API будет передан без шифрования. Как это настроить + Устройства рядом + Сканировать устройства рядом + Устройств «Локатор» в зоне досягаемости нет. AirTag и другие устройства Apple «Локатор» появляются здесь, пока открыт этот экран. + Разрешите доступ к Bluetooth, чтобы видеть устройства «Локатор» рядом. + Устройство «Локатор» · %1$s + ~%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 5f1810a5..a646404b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -423,4 +423,14 @@ 响铃失败:%1$s 不是 https:API 令牌将以未加密方式发送。 如何设置 + 附近的设备 + 扫描附近的设备 + 附近没有“查找”设备。AirTag 和其他 Apple“查找”设备会在此屏幕打开期间显示在这里。 + 允许蓝牙访问以查看附近的“查找”设备。 + “查找”设备 · %1$s + 约 %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 9bf37589..564e0edb 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -423,4 +423,14 @@ 響鈴失敗:%1$s 不是 https:API 權杖將以未加密方式傳送。 如何設定 + 附近的裝置 + 掃描附近的裝置 + 附近沒有「尋找」裝置。AirTag 和其他 Apple「尋找」裝置會在此畫面開啟期間顯示在這裡。 + 允許藍牙存取以查看附近的「尋找」裝置。 + 「尋找」裝置 · %1$s + 約 %1$s 公尺 + 與物主分離 + 在物主附近 + 藍牙已關閉或無法使用。開啟後可查看附近的 Find My 裝置 — 將自動重試。 + 離線 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f33053d..f53f7cd2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -458,4 +458,14 @@ 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 + 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/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..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 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 keepsAGonePrefixAsAnOfflineRow() { + // Departure sends nothing: silence is the only signal. A tag unheard past + // GONE_AFTER_MS stays listed as offline rather than vanishing mid-glance. + final NearbyDeviceBook book = new NearbyDeviceBook(); + book.hear(sighting("aabbccddeeff", -60, 1_000)); + + 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()); + } +} 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)); + } +}