diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java index 0d740daf..3396a13f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -1719,9 +1719,20 @@ private void handleContinuousPingUpdate(final String beaconId, final BleSoundTri } private void stopContinuousPing() { - if (this.continuousPingDisposable != null && !this.continuousPingDisposable.isDisposed()) { + final boolean wasRinging = this.continuousPingDisposable != null + && !this.continuousPingDisposable.isDisposed(); + if (wasRinging) { this.continuousPingDisposable.dispose(); } + // The last trigger may still be sounding (a DULT device plays until told otherwise or + // its own timeout), so tell it to stop rather than only not asking again + final BeaconData ringing = this.continuousPingBeaconId == null ? null + : this.beacons.get(this.continuousPingBeaconId); + if (wasRinging && ringing != null) { + final var async = AppDependencies.accessorySoundTrigger() + .stopSound(this.getApplicationContext(), ringing.getInfo().getOwnedBeaconAccessoryJson()) + .subscribe(() -> Log.d(TAG, "Stop sent (or nothing to stop)")); + } this.continuousPingDisposable = null; if (this.continuousPingBeaconId != null) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java index c7446537..3ac3b713 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java @@ -37,4 +37,13 @@ public interface AccessorySoundTrigger { * end the loop. Dispose the subscription to stop. */ Observable playSoundContinuously(Context context, String accessoryJson); + + /** + * Asks a sounding accessory to stop, where its protocol has a stop command (DULT's + * Sound_Stop). AirTags stop by themselves and have none, so the default does nothing. + * Never errors; best effort. + */ + default io.reactivex.rxjava3.core.Completable stopSound(Context context, String accessoryJson) { + return io.reactivex.rxjava3.core.Completable.complete(); + } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java index 3c3df140..ab655efb 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java @@ -195,6 +195,19 @@ public Observable playSound(final Context context, final }).subscribeOn(Schedulers.io()); } + @Override + public io.reactivex.rxjava3.core.Completable stopSound(final Context context, final String accessoryJson) { + return io.reactivex.rxjava3.core.Completable.defer(() -> { + if (!this.permissionCheck.granted(context)) return io.reactivex.rxjava3.core.Completable.complete(); + final Map candidates = this.macResolver.currentMacAddresses(accessoryJson); + if (candidates == null || candidates.isEmpty()) return io.reactivex.rxjava3.core.Completable.complete(); + return this.scanner.findNearby(context, candidates.keySet(), SCAN_TIMEOUT_MS) + .flatMapCompletable(device -> device instanceof android.bluetooth.BluetoothDevice + ? BleGattSoundTrigger.stopDult(context, (android.bluetooth.BluetoothDevice) device) + : io.reactivex.rxjava3.core.Completable.complete()); + }).onErrorComplete().subscribeOn(Schedulers.io()); + } + /** * {@link GattTrigger#trigger}, retried up to {@code attemptsLeft} times as long as each * failure is {@link BleSoundTriggerStatus#FAILED} - see {@link #GATT_ATTEMPTS}. Only the diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java index 975fc046..74915df1 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java @@ -239,4 +239,66 @@ private static void writeDescriptorCompat( gatt.writeDescriptor(descriptor); } } + + /** DULT Sound_Stop, little-endian like the start opcode. */ + private static final byte[] DULT_STOP_OPCODE = {0x01, 0x03}; + + /** + * Connects and writes DULT's Sound_Stop, if the device has the DULT service; completes + * either way (and on any failure) within about ten seconds. + */ + @SuppressLint("MissingPermission") + public static io.reactivex.rxjava3.core.Completable stopDult( + final Context context, final BluetoothDevice device) { + return io.reactivex.rxjava3.core.Completable.create(emitter -> { + final BluetoothGatt[] gattRef = new BluetoothGatt[1]; + final BluetoothGattCallback callback = new BluetoothGattCallback() { + private BluetoothGattCharacteristic dult; + + @Override + public void onConnectionStateChange(final BluetoothGatt gatt, final int status, final int newState) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + gatt.discoverServices(); + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + gatt.close(); + emitter.onComplete(); + } + } + + @Override + public void onServicesDiscovered(final BluetoothGatt gatt, final int status) { + this.dult = characteristicOf(gatt, DULT_SERVICE, DULT_CHARACTERISTIC); + if (this.dult == null) { + gatt.disconnect(); + return; + } + gatt.setCharacteristicNotification(this.dult, true); + final BluetoothGattDescriptor descriptor = this.dult.getDescriptor(CCCD); + if (descriptor == null) { + writeCharacteristicCompat(gatt, this.dult, DULT_STOP_OPCODE); + } else { + writeDescriptorCompat(gatt, descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); + } + } + + @Override + public void onDescriptorWrite(final BluetoothGatt gatt, final BluetoothGattDescriptor d, final int status) { + if (this.dult != null) writeCharacteristicCompat(gatt, this.dult, DULT_STOP_OPCODE); + } + + @Override + public void onCharacteristicWrite(final BluetoothGatt gatt, final BluetoothGattCharacteristic c, final int status) { + Log.i(TAG, "DULT stop written to " + device.getAddress() + " (status=" + status + ")"); + gatt.disconnect(); + } + }; + gattRef[0] = device.connectGatt(context, false, callback); + emitter.setCancellable(() -> { + if (gattRef[0] != null) { + gattRef[0].disconnect(); + gattRef[0].close(); + } + }); + }).timeout(10, java.util.concurrent.TimeUnit.SECONDS).onErrorComplete(); + } }