filters = Collections.singletonList(
+ new ScanFilter.Builder().setServiceData(FMDN_SERVICE_DATA, new byte[0]).build());
+ scanner.startScan(filters, new ScanSettings.Builder()
+ .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), callback);
+ emitter.setCancellable(() -> scanner.stopScan(callback));
+ });
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingCommand.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingCommand.java
new file mode 100644
index 00000000..310d5326
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingCommand.java
@@ -0,0 +1,153 @@
+package dev.wander.android.opentagviewer.ble;
+
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Locale;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * The bytes of a Google Find Hub (FMDN) owner "ring" request, per the FMDN accessory
+ * specification's Beacon Actions characteristic.
+ *
+ * Write layout: {@code dataId(0x05) | len(0x0C) | auth(8) | components | timeout(u16 BE,
+ * deciseconds) | volume}. The one-time authentication key binds the nonce the tracker handed out
+ * on the preceding read: {@code HMAC-SHA256(ringKey, version | nonce | dataId | len | additional)[:8]}, where {@code ringKey = SHA256(EIK | 0x02)[:8]} is derived by the googlefind
+ * service, which holds the identity key.
+ *
+ *
Pure Java so it can be tested on the JVM.
+ */
+public final class FmdnRingCommand {
+ public static final byte DATA_ID_RING = 0x05;
+ /** Ring every component the tracker has. */
+ public static final byte COMPONENTS_ALL = (byte) 0xFF;
+ /** Components 0 means stop. */
+ public static final byte COMPONENTS_STOP = 0x00;
+ public static final byte VOLUME_DEFAULT = 0x00;
+ // Data length counts the 8-byte authentication key plus the additional data (a length of 4
+ // made a real tracker drop the link)
+ private static final byte LENGTH = 12;
+ /** Direction byte some firmware expects appended to the HMAC input (fallback variants only). */
+ private static final byte FROM_SEEKER = 0x01;
+
+ private FmdnRingCommand() {
+ }
+
+ static byte[] additionalData(final byte components, final int timeoutDeciseconds, final byte volume) {
+ return new byte[]{components, (byte) (timeoutDeciseconds >> 8), (byte) timeoutDeciseconds, volume};
+ }
+
+ /** The 8-byte one-time authentication key. */
+ public static byte[] authKey(final byte[] ringKey, final int version, final byte[] nonce,
+ final byte dataId, final byte length, final byte[] additional) {
+ try {
+ final Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(ringKey, "HmacSHA256"));
+ mac.update((byte) version);
+ mac.update(nonce);
+ mac.update(dataId);
+ mac.update(length);
+ mac.update(additional);
+ return Arrays.copyOf(mac.doFinal(), 8);
+ } catch (final GeneralSecurityException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ /**
+ * The value to write, given what the tracker's Beacon Actions characteristic returned on read
+ * ({@code version | nonce(8)}).
+ */
+ public static byte[] ringWrite(final byte[] ringKey, final byte[] readValue, final byte components,
+ final int timeoutDeciseconds, final byte volume) {
+ return ringWrite(ringKey, readValue, components, timeoutDeciseconds, volume, VARIANTS[0]);
+ }
+
+ /**
+ * One way of phrasing the ring request. Trackers differ: some predate the volume byte, and
+ * implementations disagree on the trailing direction byte in the HMAC input.
+ */
+ public static final class Variant {
+ public final boolean withVolume;
+ public final boolean trailingDirection;
+
+ public Variant(final boolean withVolume, final boolean trailingDirection) {
+ this.withVolume = withVolume;
+ this.trailingDirection = trailingDirection;
+ }
+
+ @Override
+ public String toString() {
+ return (withVolume ? "4-byte" : "3-byte") + (trailingDirection ? "+dir" : "");
+ }
+ }
+
+ /**
+ * Tried in this order. The first is what a real tracker accepted: four bytes of additional
+ * data and no trailing direction byte (that byte belongs to the tracker's own notification
+ * authentication, not to the seeker's request). The rest are fallbacks for other firmware.
+ */
+ public static final Variant[] VARIANTS = {
+ new Variant(true, false), new Variant(false, false),
+ new Variant(true, true), new Variant(false, true),
+ };
+
+ public static byte[] ringWrite(final byte[] ringKey, final byte[] readValue, final byte components,
+ final int timeoutDeciseconds, final byte volume, final Variant variant) {
+ if (readValue == null || readValue.length < 9) {
+ throw new IllegalArgumentException("Beacon Actions read returned no nonce");
+ }
+ final int version = readValue[0] & 0xFF;
+ final byte[] nonce = Arrays.copyOfRange(readValue, 1, 9);
+ final byte[] full = additionalData(components, timeoutDeciseconds, volume);
+ final byte[] additional = variant.withVolume ? full : Arrays.copyOf(full, 3);
+ final byte length = (byte) (8 + additional.length);
+ final byte[] auth;
+ try {
+ final Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(ringKey, "HmacSHA256"));
+ mac.update((byte) version);
+ mac.update(nonce);
+ mac.update(DATA_ID_RING);
+ mac.update(length);
+ mac.update(additional);
+ if (variant.trailingDirection) mac.update(FROM_SEEKER);
+ auth = Arrays.copyOf(mac.doFinal(), 8);
+ } catch (final GeneralSecurityException e) {
+ throw new IllegalStateException(e);
+ }
+ final byte[] out = new byte[2 + 8 + additional.length];
+ out[0] = DATA_ID_RING;
+ out[1] = length;
+ System.arraycopy(auth, 0, out, 2, 8);
+ System.arraycopy(additional, 0, out, 10, additional.length);
+ return out;
+ }
+
+ /**
+ * Whether FMDN service data (UUID 0xFEAA: frame type, then the 20-byte EID) carries one of
+ * the given EIDs, as lowercase hex.
+ */
+ public static boolean matchesEid(final byte[] serviceData, final Collection eidsHex) {
+ if (serviceData == null || serviceData.length < 21) return false;
+ final int frame = serviceData[0] & 0xFF;
+ if (frame != 0x40 && frame != 0x41) return false;
+ return eidsHex.contains(hex(Arrays.copyOfRange(serviceData, 1, 21)));
+ }
+
+ public static String hex(final byte[] bytes) {
+ final StringBuilder sb = new StringBuilder(bytes.length * 2);
+ for (final byte b : bytes) sb.append(String.format(Locale.ROOT, "%02x", b));
+ return sb.toString();
+ }
+
+ public static byte[] unhex(final String hex) {
+ final byte[] out = new byte[hex.length() / 2];
+ for (int i = 0; i < out.length; i++) {
+ out[i] = (byte) Integer.parseInt(hex.substring(2 * i, 2 * i + 2), 16);
+ }
+ return out;
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingTrigger.java
new file mode 100644
index 00000000..576b6faf
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingTrigger.java
@@ -0,0 +1,286 @@
+package dev.wander.android.opentagviewer.ble;
+
+import android.annotation.SuppressLint;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanResult;
+import android.bluetooth.le.ScanSettings;
+import android.content.Context;
+import android.os.ParcelUuid;
+import android.util.Log;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import dev.wander.android.opentagviewer.source.ExternalAccessory;
+import dev.wander.android.opentagviewer.source.ExtraSourcesApi;
+import dev.wander.android.opentagviewer.source.ExtraSourcesSettings;
+import io.reactivex.rxjava3.core.Completable;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.core.Single;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
+/**
+ * Rings a Google Find Hub tracker the way its owner's Find Hub app does: the tracker is
+ * recognised by its current EID in the FMDN advertisement (service data 0xFEAA), then its Beacon
+ * Actions characteristic is read for a nonce and written a ring request authenticated with the
+ * ring key (see {@link FmdnRingCommand}). The ring key and EIDs come from the googlefind service,
+ * which holds the tracker's identity key; the ring key rings and nothing else.
+ *
+ * Same contract as {@link BleAccessorySoundTrigger}: progress updates, exactly one DONE, never
+ * an error.
+ */
+@SuppressLint("MissingPermission") // checked through BlePermissions first
+public final class FmdnRingTrigger implements AccessorySoundTrigger {
+ private static final String TAG = FmdnRingTrigger.class.getSimpleName();
+ private static final String PROTOCOL = "FMDN";
+
+ private static final ParcelUuid FMDN_SERVICE_DATA = ParcelUuid.fromString("0000feaa-0000-1000-8000-00805f9b34fb");
+ private static final UUID FAST_PAIR_SERVICE = UUID.fromString("0000fe2c-0000-1000-8000-00805f9b34fb");
+ private static final UUID BEACON_ACTIONS = UUID.fromString("fe2c1238-8366-4814-8eb0-01de32100bea");
+ private static final UUID CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
+
+ private static final long SCAN_TIMEOUT_S = 30;
+ private static final long GATT_TIMEOUT_S = 20;
+ /** One ring lasts this long (deciseconds); continuous ringing re-rings before it ends. */
+ private static final int RING_DECISECONDS = 300;
+ private static final long CONTINUOUS_PAUSE_S = 20;
+
+ @Override
+ public Observable playSound(final Context context, final String accessoryJson) {
+ return this.run(context, accessoryJson, FmdnRingCommand.COMPONENTS_ALL);
+ }
+
+ @Override
+ public Observable playSoundContinuously(final Context context, final String accessoryJson) {
+ return this.playSound(context, accessoryJson)
+ .repeatWhen(completed -> completed.delay(CONTINUOUS_PAUSE_S, TimeUnit.SECONDS));
+ }
+
+ @Override
+ public Completable stopSound(final Context context, final String accessoryJson) {
+ return this.run(context, accessoryJson, FmdnRingCommand.COMPONENTS_STOP).ignoreElements().onErrorComplete();
+ }
+
+ private static BleSoundTriggerUpdate done(final BleSoundTriggerStatus status, final String message) {
+ return BleSoundTriggerUpdate.done(new BleSoundTriggerResult(
+ status, status == BleSoundTriggerStatus.SUCCESS ? PROTOCOL : null, message));
+ }
+
+ private Observable run(final Context context, final String accessoryJson,
+ final byte components) {
+ return Observable.defer(() -> {
+ if (!BlePermissions.granted(context)) {
+ return Observable.just(done(BleSoundTriggerStatus.MISSING_PERMISSION,
+ "Bluetooth scan/connect permission not granted"));
+ }
+ final String googleId = ExternalAccessory.googleId(accessoryJson);
+ if (googleId == null) {
+ return Observable.just(done(BleSoundTriggerStatus.NO_CANDIDATE_MACS, "Not a Google Find Hub tracker"));
+ }
+
+ final ExtraSourcesApi.RingMaterial material;
+ try {
+ material = new ExtraSourcesApi(new ExtraSourcesSettings(context)).ringMaterial(googleId);
+ } catch (final Exception e) {
+ Log.w(TAG, "Could not get ring material for " + googleId, e);
+ return Observable.just(done(BleSoundTriggerStatus.NO_CANDIDATE_MACS,
+ "Could not get the ring key from the Google Find Hub service: " + e.getMessage()));
+ }
+
+ return Observable.just(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.SCANNING))
+ .concatWith(findByEid(context, material.eids).toObservable()
+ .concatMap(device -> Observable.just(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.CONNECTING))
+ .concatWith(ring(context, device, material.ringKey, components)
+ .map(update -> update.withMatchedMac(device.getAddress())))))
+ .onErrorReturn(e -> e instanceof NotNearby
+ ? done(BleSoundTriggerStatus.NOT_NEARBY, e.getMessage())
+ : done(BleSoundTriggerStatus.FAILED, String.valueOf(e.getMessage())));
+ }).subscribeOn(Schedulers.io());
+ }
+
+ private static final class NotNearby extends Exception {
+ NotNearby(final String message) {
+ super(message);
+ }
+ }
+
+ /** Scans for an FMDN advertisement carrying one of the EIDs. */
+ private static Single findByEid(final Context context, final List eids) {
+ return Single.create(emitter -> {
+ final BluetoothManager manager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
+ final BluetoothAdapter adapter = manager == null ? null : manager.getAdapter();
+ final BluetoothLeScanner scanner = adapter == null || !adapter.isEnabled() ? null : adapter.getBluetoothLeScanner();
+ if (scanner == null) {
+ emitter.tryOnError(new IllegalStateException("Bluetooth is off"));
+ return;
+ }
+ final AtomicBoolean found = new AtomicBoolean(false);
+ final java.util.concurrent.atomic.AtomicInteger seen = new java.util.concurrent.atomic.AtomicInteger();
+ Log.i(TAG, "Scanning for an FMDN tracker matching one of " + eids.size() + " EIDs");
+ final ScanCallback callback = new ScanCallback() {
+ @Override
+ public void onScanResult(final int callbackType, final ScanResult result) {
+ final byte[] data = result.getScanRecord() == null ? null
+ : result.getScanRecord().getServiceData(FMDN_SERVICE_DATA);
+ if (data != null && seen.incrementAndGet() <= 40) {
+ Log.d(TAG, "FEAA " + FmdnRingCommand.hex(data) + " from " + result.getDevice().getAddress()
+ + (FmdnRingCommand.matchesEid(data, eids) ? " MATCH" : ""));
+ }
+ if (FmdnRingCommand.matchesEid(data, eids) && found.compareAndSet(false, true)) {
+ emitter.onSuccess(result.getDevice());
+ }
+ }
+
+ @Override
+ public void onScanFailed(final int errorCode) {
+ emitter.tryOnError(new IllegalStateException("Bluetooth scan failed (" + errorCode + ")"));
+ }
+ };
+ // Any FMDN advertisement; the EID is compared in the callback
+ final ScanFilter filter = new ScanFilter.Builder().setServiceData(FMDN_SERVICE_DATA, new byte[0]).build();
+ scanner.startScan(Collections.singletonList(filter),
+ new ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), callback);
+ emitter.setCancellable(() -> scanner.stopScan(callback));
+ }).timeout(SCAN_TIMEOUT_S, TimeUnit.SECONDS, Single.error(new NotNearby("Tracker not found nearby")));
+ }
+
+ /** Connect, read the nonce, write the ring (or stop) request. */
+ private static Observable ring(final Context context, final BluetoothDevice device,
+ final byte[] ringKey, final byte components) {
+ return Observable.create(emitter -> {
+ final AtomicBoolean finished = new AtomicBoolean(false);
+ final BluetoothGatt[] gattRef = new BluetoothGatt[1];
+
+ final BluetoothGattCallback callback = new BluetoothGattCallback() {
+ private BluetoothGattCharacteristic actions;
+ private int variant = 0;
+
+ private void finish(final BleSoundTriggerUpdate update, final BluetoothGatt gatt) {
+ if (!finished.compareAndSet(false, true)) return;
+ if (!emitter.isDisposed()) {
+ emitter.onNext(update);
+ emitter.onComplete();
+ }
+ gatt.disconnect();
+ }
+
+ @Override
+ public void onConnectionStateChange(final BluetoothGatt gatt, final int status, final int newState) {
+ Log.i(TAG, "Connection state " + newState + " (status=" + status + ") " + device.getAddress());
+ if (newState == BluetoothProfile.STATE_CONNECTED) {
+ gatt.discoverServices();
+ } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
+ gatt.close();
+ this.finish(done(BleSoundTriggerStatus.FAILED,
+ "Connection closed before the ring request was sent"), gatt);
+ }
+ }
+
+ @Override
+ public void onServicesDiscovered(final BluetoothGatt gatt, final int status) {
+ final BluetoothGattService fastPair = gatt.getService(FAST_PAIR_SERVICE);
+ this.actions = fastPair == null ? null : fastPair.getCharacteristic(BEACON_ACTIONS);
+ if (this.actions == null) {
+ // Some trackers expose Beacon Actions outside the Fast Pair service
+ for (final BluetoothGattService svc : gatt.getServices()) {
+ final BluetoothGattCharacteristic c = svc.getCharacteristic(BEACON_ACTIONS);
+ if (c != null) { this.actions = c; break; }
+ }
+ }
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ Log.w(TAG, "Service discovery status=" + status);
+ }
+ if (this.actions == null) {
+ final StringBuilder found = new StringBuilder();
+ for (final BluetoothGattService svc : gatt.getServices()) found.append(svc.getUuid()).append(' ');
+ Log.w(TAG, "No Beacon Actions char on " + device.getAddress() + "; services: " + found);
+ this.finish(done(BleSoundTriggerStatus.NO_SOUND_SERVICE,
+ "No FMDN Beacon Actions characteristic"), gatt);
+ return;
+ }
+ if (!emitter.isDisposed()) {
+ emitter.onNext(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.TRIGGERING));
+ }
+ final BluetoothGattDescriptor cccd = this.actions.getDescriptor(CCCD);
+ if (cccd != null) {
+ gatt.setCharacteristicNotification(this.actions, true);
+ gatt.writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
+ } else {
+ gatt.readCharacteristic(this.actions);
+ }
+ }
+
+ @Override
+ public void onCharacteristicChanged(final BluetoothGatt gatt, final BluetoothGattCharacteristic c, final byte[] value) {
+ Log.i(TAG, "Beacon Actions notification: " + FmdnRingCommand.hex(value));
+ }
+
+ @Override
+ public void onDescriptorWrite(final BluetoothGatt gatt, final BluetoothGattDescriptor d, final int status) {
+ Log.i(TAG, "CCCD write status=" + status);
+ gatt.readCharacteristic(this.actions);
+ }
+
+ @Override
+ public void onCharacteristicRead(final BluetoothGatt gatt, final BluetoothGattCharacteristic c,
+ final byte[] value, final int status) {
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ this.finish(done(BleSoundTriggerStatus.FAILED, "Reading the nonce failed (status=" + status + ")"), gatt);
+ return;
+ }
+ Log.i(TAG, "Beacon Actions read: " + FmdnRingCommand.hex(value == null ? new byte[0] : value));
+ try {
+ final byte[] request = FmdnRingCommand.ringWrite(ringKey, value, components,
+ RING_DECISECONDS, FmdnRingCommand.VOLUME_DEFAULT,
+ FmdnRingCommand.VARIANTS[this.variant]);
+ Log.i(TAG, "Writing: " + FmdnRingCommand.hex(request));
+ gatt.writeCharacteristic(this.actions, request, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT);
+ } catch (final IllegalArgumentException e) {
+ this.finish(done(BleSoundTriggerStatus.FAILED, e.getMessage()), gatt);
+ }
+ }
+
+ @Override
+ public void onCharacteristicWrite(final BluetoothGatt gatt, final BluetoothGattCharacteristic c, final int status) {
+ Log.i(TAG, "Ring request (components=" + components + ", variant "
+ + FmdnRingCommand.VARIANTS[this.variant] + ") written to "
+ + device.getAddress() + ", status=" + status);
+ // 0x80 is the spec's "unauthenticated": the tracker is still connected, so try
+ // the next phrasing with a fresh nonce
+ if (status == 0x80 && this.variant + 1 < FmdnRingCommand.VARIANTS.length) {
+ this.variant++;
+ gatt.readCharacteristic(this.actions);
+ return;
+ }
+ this.finish(status == BluetoothGatt.GATT_SUCCESS
+ ? done(BleSoundTriggerStatus.SUCCESS, null)
+ // The tracker rejects a bad one-time key with an ATT error
+ : done(BleSoundTriggerStatus.FAILED, "The tracker refused the ring request (status=" + status + ")"), gatt);
+ }
+ };
+
+ gattRef[0] = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE);
+ emitter.setCancellable(() -> {
+ if (gattRef[0] != null) {
+ gattRef[0].disconnect();
+ gattRef[0].close();
+ }
+ });
+ }).timeout(GATT_TIMEOUT_S, TimeUnit.SECONDS,
+ Observable.just(done(BleSoundTriggerStatus.FAILED, "The tracker did not answer in time")));
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/RoutingSoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/RoutingSoundTrigger.java
new file mode 100644
index 00000000..c24accf7
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/RoutingSoundTrigger.java
@@ -0,0 +1,40 @@
+package dev.wander.android.opentagviewer.ble;
+
+import android.content.Context;
+
+import dev.wander.android.opentagviewer.source.ExternalAccessory;
+import io.reactivex.rxjava3.core.Completable;
+import io.reactivex.rxjava3.core.Observable;
+
+/**
+ * Picks the sound protocol by the kind of device: Google Find Hub trackers ring through FMDN
+ * ({@link FmdnRingTrigger}), everything else through the Find My / DULT path.
+ */
+public final class RoutingSoundTrigger implements AccessorySoundTrigger {
+ private final AccessorySoundTrigger findMy;
+ private final AccessorySoundTrigger fmdn;
+
+ public RoutingSoundTrigger(final AccessorySoundTrigger findMy, final AccessorySoundTrigger fmdn) {
+ this.findMy = findMy;
+ this.fmdn = fmdn;
+ }
+
+ private AccessorySoundTrigger forDevice(final String accessoryJson) {
+ return ExternalAccessory.isGoogle(accessoryJson) ? this.fmdn : this.findMy;
+ }
+
+ @Override
+ public Observable playSound(final Context context, final String accessoryJson) {
+ return this.forDevice(accessoryJson).playSound(context, accessoryJson);
+ }
+
+ @Override
+ public Observable playSoundContinuously(final Context context, final String accessoryJson) {
+ return this.forDevice(accessoryJson).playSoundContinuously(context, accessoryJson);
+ }
+
+ @Override
+ public Completable stopSound(final Context context, final String accessoryJson) {
+ return this.forDevice(accessoryJson).stopSound(context, accessoryJson);
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
index b6514156..3937427a 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
@@ -958,10 +958,12 @@ public Observable