mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Garmin: Initial MLR support
This commit is contained in:
+1
@@ -242,6 +242,7 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
|
||||
developer.add(R.xml.devicesettings_keep_activity_data_on_device);
|
||||
developer.add(R.xml.devicesettings_fetch_unknown_files);
|
||||
developer.add(R.xml.devicesettings_new_sync_protocol);
|
||||
developer.add(R.xml.devicesettings_garmin_mlr);
|
||||
|
||||
return deviceSpecificSettings;
|
||||
}
|
||||
|
||||
+19
-5
@@ -267,6 +267,14 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
|
||||
return communicator.onCharacteristicChanged(gatt, characteristic, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnectionStateChange(final BluetoothGatt gatt, final int status, final int newState) {
|
||||
super.onConnectionStateChange(gatt, status, newState);
|
||||
if (communicator != null) {
|
||||
communicator.onConnectionStateChange(gatt, status, newState);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(final byte[] message) {
|
||||
if (null == message) {
|
||||
@@ -277,9 +285,11 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
|
||||
GFDIMessage parsedMessage = GFDIMessage.parseIncoming(message);
|
||||
|
||||
if (null == parsedMessage) {
|
||||
LOG.error("GFDIMessage is null - this should never happen");
|
||||
return; //message cannot be handled
|
||||
}
|
||||
|
||||
LOG.debug("Got GFDIMessage {} ({} bytes)", parsedMessage.getClass().getSimpleName(), message.length);
|
||||
|
||||
/*
|
||||
the handler elaborates the followup message but might change the status message since it does
|
||||
@@ -443,13 +453,13 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
|
||||
|
||||
@Override
|
||||
public void onFetchRecordedData(final int dataTypes) {
|
||||
if (this.supportedFileTypeList.isEmpty()) {
|
||||
LOG.warn("No known supported file types");
|
||||
if (dataTypes == RecordedDataTypes.TYPE_DEBUGLOGS) {
|
||||
sendOutgoingMessage("fetch debug data", fileTransferHandler.initiateDebugDownload());
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataTypes == RecordedDataTypes.TYPE_DEBUGLOGS){
|
||||
sendOutgoingMessage("fetch debug data", fileTransferHandler.initiateDebugDownload());
|
||||
if (this.supportedFileTypeList.isEmpty() && !newSyncProtocol()) {
|
||||
LOG.warn("No known supported file types");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -464,6 +474,10 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
|
||||
return getDevicePrefs().getBoolean("new_sync_protocol", false);
|
||||
}
|
||||
|
||||
public boolean mlrEnabled() {
|
||||
return getDevicePrefs().getBoolean("garmin_mlr", false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNotification(final NotificationSpec notificationSpec) {
|
||||
sendOutgoingMessage("send notification " + notificationSpec.getId(), notificationsHandler.onNotification(notificationSpec));
|
||||
@@ -1231,7 +1245,7 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
|
||||
}
|
||||
|
||||
public void downloadFileFromServiceV2(final int fileHandle) {
|
||||
LOG.warn("Requesting file service V2 handle={}", fileHandle);
|
||||
LOG.info("Requesting file service V2 handle={}", fileHandle);
|
||||
if (!(communicator instanceof CommunicatorV2 communicatorV2)) {
|
||||
LOG.error("Communicator is not V2");
|
||||
return;
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ public class CobsCoDec {
|
||||
}
|
||||
|
||||
// this implementation of COBS relies on a leading and a trailing 0 byte (the former is not part of default implementations)
|
||||
public byte[] encode(byte[] data) {
|
||||
public static byte[] encode(byte[] data) {
|
||||
ByteBuffer encodedBytesBuffer = ByteBuffer.allocate((data.length * 2) + 1); // Maximum expansion
|
||||
|
||||
encodedBytesBuffer.put((byte) 0);// Garmin initial padding
|
||||
|
||||
+4
@@ -12,6 +12,10 @@ public interface ICommunicator {
|
||||
|
||||
boolean initializeDevice(TransactionBuilder builder);
|
||||
|
||||
void dispose();
|
||||
|
||||
void onConnectionStateChange(BluetoothGatt gatt, int status, int newState);
|
||||
|
||||
boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value);
|
||||
|
||||
void onHeartRateTest();
|
||||
|
||||
+10
@@ -69,6 +69,16 @@ public class CommunicatorV1 implements ICommunicator {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnectionStateChange(final BluetoothGatt gatt, final int status, final int newState) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendMessage(final String taskName, final byte[] message) {
|
||||
if (null == message)
|
||||
|
||||
+216
-161
@@ -19,6 +19,7 @@ import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
||||
@@ -33,6 +34,7 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminTimeUtils;
|
||||
@@ -57,30 +59,24 @@ public class CommunicatorV2 implements ICommunicator {
|
||||
private final Map<Service, Integer> handleByService = new HashMap<>();
|
||||
private final Map<Service, ServiceCallback> serviceCallbacks = new HashMap<>();
|
||||
|
||||
private int gfdiHandle = 0;
|
||||
public int maxWriteSize = 20;
|
||||
public final CobsCoDec cobsCoDec;
|
||||
|
||||
private int realtimeHrHandle = 0;
|
||||
private boolean realtimeHrOneShot = false;
|
||||
|
||||
private int realtimeStepsHandle = 0;
|
||||
private int previousSteps = -1;
|
||||
|
||||
private int realtimeAccelHandle = 0;
|
||||
|
||||
private int realtimeSpo2Handle = 0;
|
||||
private int realtimeRespirationHandle = 0;
|
||||
private int realtimeHrvHandle = 0;
|
||||
// MLR support
|
||||
private final Map<Integer, MlrCommunicator> mlrCommunicators = new HashMap<>();
|
||||
|
||||
public CommunicatorV2(final GarminSupport garminSupport) {
|
||||
this.mSupport = garminSupport;
|
||||
this.cobsCoDec = new CobsCoDec();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMtuChanged(final int mtu) {
|
||||
maxWriteSize = calcMaxWriteChunk(mtu);
|
||||
for (MlrCommunicator communicator : mlrCommunicators.values()) {
|
||||
communicator.setMaxPacketSize(maxWriteSize);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -106,28 +102,49 @@ public class CommunicatorV2 implements ICommunicator {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
// Close all MLR communicators
|
||||
for (MlrCommunicator mlrComm : mlrCommunicators.values()) {
|
||||
mlrComm.close();
|
||||
}
|
||||
mlrCommunicators.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnectionStateChange(final BluetoothGatt gatt, final int status, final int newState) {
|
||||
for (MlrCommunicator mlrComm : mlrCommunicators.values()) {
|
||||
mlrComm.onConnectionStateChange(gatt, status, newState);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendMessage(final String taskName, final byte[] message) {
|
||||
if (null == message)
|
||||
return;
|
||||
if (0 == gfdiHandle) {
|
||||
final Integer gfdiHandle = handleByService.get(Service.GFDI);
|
||||
if (gfdiHandle == null) {
|
||||
LOG.error("CANNOT SENT GFDI MESSAGE, HANDLE NOT YET SET. MESSAGE {}", message);
|
||||
return;
|
||||
}
|
||||
final byte[] payload = cobsCoDec.encode(message);
|
||||
// LOG.debug("SENDING MESSAGE: {} - COBS ENCODED: {}", GB.hexdump(message), GB.hexdump(payload));
|
||||
final byte[] payload = CobsCoDec.encode(message);
|
||||
final MlrCommunicator mlr = mlrCommunicators.get(gfdiHandle);
|
||||
if (mlr != null) {
|
||||
mlr.sendMessage(taskName, payload);
|
||||
return;
|
||||
}
|
||||
final TransactionBuilder builder = mSupport.createTransactionBuilder(taskName);
|
||||
int remainingBytes = payload.length;
|
||||
if (remainingBytes > maxWriteSize - 1) {
|
||||
int position = 0;
|
||||
while (remainingBytes > 0) {
|
||||
final byte[] fragment = Arrays.copyOfRange(payload, position, position + Math.min(remainingBytes, maxWriteSize - 1));
|
||||
builder.write(characteristicSend, ArrayUtils.addAll(new byte[]{(byte) gfdiHandle}, fragment));
|
||||
builder.write(characteristicSend, ArrayUtils.addAll(new byte[]{gfdiHandle.byteValue()}, fragment));
|
||||
position += fragment.length;
|
||||
remainingBytes -= fragment.length;
|
||||
}
|
||||
} else {
|
||||
builder.write(characteristicSend, ArrayUtils.addAll(new byte[]{(byte) gfdiHandle}, payload));
|
||||
builder.write(characteristicSend, ArrayUtils.addAll(new byte[]{gfdiHandle.byteValue()}, payload));
|
||||
}
|
||||
builder.queue();
|
||||
}
|
||||
@@ -139,37 +156,36 @@ public class CommunicatorV2 implements ICommunicator {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((value[0] & MlrCommunicator.MLR_FLAG_MASK) != 0) {
|
||||
// MLR packet - extract handle and forward to appropriate MLR communicator, but keep the mlr flag
|
||||
final int handle = ((value[0] & MlrCommunicator.HANDLE_MASK) >> 4) | MlrCommunicator.MLR_FLAG_MASK;
|
||||
final MlrCommunicator mlrComm = mlrCommunicators.get(handle);
|
||||
if (mlrComm == null) {
|
||||
LOG.warn("Received MLR packet for unknown handle: {}", handle);
|
||||
return true;
|
||||
}
|
||||
mlrComm.onPacketReceived(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
final ByteBuffer message = ByteBuffer.wrap(value).order(ByteOrder.LITTLE_ENDIAN);
|
||||
final byte handle = message.get();
|
||||
|
||||
if (0x00 == handle) {
|
||||
processHandleManagement(message);
|
||||
} else if (this.gfdiHandle == handle) {
|
||||
processGfdi(message);
|
||||
} else if (this.realtimeHrHandle == handle) {
|
||||
processRealtimeHeartRate(message);
|
||||
} else if (this.realtimeStepsHandle == handle) {
|
||||
processRealtimeSteps(message);
|
||||
} else if (this.realtimeAccelHandle == handle) {
|
||||
processRealtimeAccelerometer(message);
|
||||
} else if (this.realtimeSpo2Handle == handle) {
|
||||
processRealtimeSpo2(message);
|
||||
} else if (this.realtimeRespirationHandle == handle) {
|
||||
processRealtimeRespiration(message);
|
||||
} else if (this.realtimeHrvHandle == handle) {
|
||||
processRealtimeHrv(message);
|
||||
} else {
|
||||
final Service service = serviceByHandle.get(handle & 0xff);
|
||||
if (service != null) {
|
||||
final ServiceCallback serviceCallback = serviceCallbacks.get(service);
|
||||
if (serviceCallback != null) {
|
||||
serviceCallback.onMessage(Arrays.copyOfRange(value, 1, value.length));
|
||||
} else {
|
||||
LOG.warn("Got message for {}, but no callback found", service);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
final Service service = serviceByHandle.get(handle & 0xff);
|
||||
if (service != null) {
|
||||
final ServiceCallback serviceCallback = serviceCallbacks.get(service);
|
||||
if (serviceCallback != null) {
|
||||
serviceCallback.onMessage(Arrays.copyOfRange(value, 1, value.length));
|
||||
} else {
|
||||
LOG.warn("Got message for unknown service on handle {}: {}", handle, GB.hexdump(value));
|
||||
LOG.warn("Got message for {}, but no callback found", service);
|
||||
}
|
||||
} else {
|
||||
LOG.warn("Got message for unknown service on handle {}: {}", handle, GB.hexdump(value));
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -197,14 +213,14 @@ public class CommunicatorV2 implements ICommunicator {
|
||||
|
||||
serviceCallbacks.put(service, callback);
|
||||
mSupport.createTransactionBuilder("start file transfer")
|
||||
.write(characteristicSend, registerService(service, false))
|
||||
.write(characteristicSend, registerService(service, mSupport.mlrEnabled()))
|
||||
.queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHeartRateTest() {
|
||||
realtimeHrOneShot = true;
|
||||
if (realtimeHrHandle == 0) {
|
||||
if (!handleByService.containsKey(Service.REALTIME_HR)) {
|
||||
mSupport.createTransactionBuilder("heart rate test")
|
||||
.write(characteristicSend, registerService(Service.REALTIME_HR, false))
|
||||
.queue();
|
||||
@@ -213,17 +229,18 @@ public class CommunicatorV2 implements ICommunicator {
|
||||
|
||||
@Override
|
||||
public void onEnableRealtimeHeartRateMeasurement(final boolean enable) {
|
||||
toggleService(Service.REALTIME_HR, realtimeHrHandle, enable);
|
||||
toggleService(Service.REALTIME_HR, enable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnableRealtimeSteps(final boolean enable) {
|
||||
if (toggleService(Service.REALTIME_STEPS, realtimeStepsHandle, enable)) {
|
||||
if (toggleService(Service.REALTIME_STEPS, enable)) {
|
||||
previousSteps = -1;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean toggleService(final Service service, final int currentHandle, final boolean enable) {
|
||||
private boolean toggleService(final Service service, final boolean enable) {
|
||||
final int currentHandle = Objects.requireNonNull(handleByService.getOrDefault(service, 0));
|
||||
if (enable && currentHandle == 0) {
|
||||
mSupport.createTransactionBuilder(service + " = true")
|
||||
.write(characteristicSend, registerService(service, false))
|
||||
@@ -273,52 +290,57 @@ public class CommunicatorV2 implements ICommunicator {
|
||||
LOG.warn("Failed to register {}, status={}", registeredService, status);
|
||||
return;
|
||||
}
|
||||
final int handle = message.get();
|
||||
final int handle = message.get() & 0xff;
|
||||
final int reliable = message.get();
|
||||
LOG.debug("Got register response for {}, handle={}, reliable={}", registeredService, handle, reliable);
|
||||
|
||||
serviceByHandle.put(handle, registeredService);
|
||||
handleByService.put(registeredService, handle);
|
||||
|
||||
switch (registeredService) {
|
||||
final ServiceCallback serviceCallback = switch (registeredService) {
|
||||
case GFDI:
|
||||
this.gfdiHandle = handle;
|
||||
break;
|
||||
yield new GfdiCallback(mSupport);
|
||||
case REALTIME_HR:
|
||||
this.realtimeHrHandle = handle;
|
||||
break;
|
||||
yield new RealtimeHeartRateCallback();
|
||||
case REALTIME_STEPS:
|
||||
this.realtimeStepsHandle = handle;
|
||||
break;
|
||||
yield new RealtimeStepsCallback();
|
||||
case REALTIME_ACCELEROMETER:
|
||||
this.realtimeAccelHandle = handle;
|
||||
mSupport.createTransactionBuilder("start realtime accel")
|
||||
.write(characteristicSend, new byte[]{(byte) handle, 0x01})
|
||||
.queue();
|
||||
break;
|
||||
yield new RealtimeAccelerometerCallback();
|
||||
case REALTIME_SPO2:
|
||||
this.realtimeSpo2Handle = handle;
|
||||
break;
|
||||
yield new RealtimeSpo2Callback();
|
||||
case REALTIME_RESPIRATION:
|
||||
this.realtimeRespirationHandle = handle;
|
||||
break;
|
||||
yield new RealtimeRespirationCallback();
|
||||
case REALTIME_HRV:
|
||||
this.realtimeHrvHandle = handle;
|
||||
break;
|
||||
yield new RealtimeHrvCallback();
|
||||
case FILE_TRANSFER_2:
|
||||
case FILE_TRANSFER_4:
|
||||
case FILE_TRANSFER_6:
|
||||
case FILE_TRANSFER_A:
|
||||
case FILE_TRANSFER_C:
|
||||
case FILE_TRANSFER_E:
|
||||
final ServiceCallback serviceCallback = serviceCallbacks.get(registeredService);
|
||||
if (serviceCallback == null) {
|
||||
LOG.error("Got file transfer registration, but got no callback");
|
||||
closeService(registeredService, handle);
|
||||
break;
|
||||
} else {
|
||||
serviceCallback.onConnect(new ServiceWriter(handle));
|
||||
}
|
||||
// For these, the callback should have been provided by the caller in startTransfer
|
||||
yield serviceCallbacks.get(registeredService);
|
||||
default:
|
||||
LOG.error("Got register response for unknown service {}", registeredService);
|
||||
yield null;
|
||||
};
|
||||
|
||||
if (serviceCallback == null) {
|
||||
LOG.error("Got service registration, but got no callback");
|
||||
closeService(registeredService, handle);
|
||||
return;
|
||||
}
|
||||
|
||||
serviceCallbacks.put(registeredService, serviceCallback);
|
||||
|
||||
if (reliable != 0) {
|
||||
// MLR mode - create reliable communicator
|
||||
final MlrCommunicator mlrComm = createMlrCommunicator(handle, serviceCallback);
|
||||
mlrCommunicators.put(handle, mlrComm);
|
||||
serviceCallback.onConnect(new MlrServiceWriter(mlrComm));
|
||||
} else {
|
||||
// Regular ML mode
|
||||
serviceCallback.onConnect(new MlServiceWriter(handle));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -329,60 +351,37 @@ public class CommunicatorV2 implements ICommunicator {
|
||||
final byte status = message.get();
|
||||
LOG.debug("Received close handle response: service={}, handle={}, status={}", service, handle, status);
|
||||
if (service != null) {
|
||||
switch (service) {
|
||||
case GFDI:
|
||||
this.gfdiHandle = 0;
|
||||
break;
|
||||
case REALTIME_HR:
|
||||
this.realtimeHrHandle = 0;
|
||||
break;
|
||||
case REALTIME_STEPS:
|
||||
this.realtimeStepsHandle = 0;
|
||||
break;
|
||||
case REALTIME_ACCELEROMETER:
|
||||
this.realtimeAccelHandle = 0;
|
||||
break;
|
||||
case REALTIME_SPO2:
|
||||
this.realtimeSpo2Handle = 0;
|
||||
break;
|
||||
case REALTIME_RESPIRATION:
|
||||
this.realtimeRespirationHandle = 0;
|
||||
break;
|
||||
case REALTIME_HRV:
|
||||
this.realtimeHrvHandle = 0;
|
||||
break;
|
||||
case FILE_TRANSFER_2:
|
||||
case FILE_TRANSFER_4:
|
||||
case FILE_TRANSFER_6:
|
||||
case FILE_TRANSFER_A:
|
||||
case FILE_TRANSFER_C:
|
||||
case FILE_TRANSFER_E:
|
||||
final ServiceCallback serviceCallback = serviceCallbacks.get(service);
|
||||
if (serviceCallback == null) {
|
||||
LOG.error("Got file transfer registration, but got no callback");
|
||||
} else {
|
||||
serviceCallback.onClose();
|
||||
}
|
||||
break;
|
||||
final ServiceCallback serviceCallback = serviceCallbacks.get(service);
|
||||
if (serviceCallback == null) {
|
||||
LOG.error("Got service registration close, but got no callback");
|
||||
} else {
|
||||
serviceCallback.onClose();
|
||||
}
|
||||
// Clean up MLR communicator if it exists
|
||||
final MlrCommunicator mlrComm = mlrCommunicators.get(handle);
|
||||
if (mlrComm != null) {
|
||||
mlrComm.close();
|
||||
mlrCommunicators.remove(handle);
|
||||
}
|
||||
|
||||
handleByService.remove(service);
|
||||
serviceCallbacks.remove(service);
|
||||
}
|
||||
|
||||
serviceByHandle.remove(handle);
|
||||
handleByService.remove(service);
|
||||
serviceCallbacks.remove(service);
|
||||
|
||||
break;
|
||||
}
|
||||
case CLOSE_ALL_RESP:
|
||||
LOG.debug("Received close all handles response. Message: {}", message.array());
|
||||
this.gfdiHandle = 0;
|
||||
this.realtimeHrHandle = 0;
|
||||
this.realtimeStepsHandle = 0;
|
||||
this.realtimeAccelHandle = 0;
|
||||
this.realtimeSpo2Handle = 0;
|
||||
this.realtimeRespirationHandle = 0;
|
||||
this.realtimeHrvHandle = 0;
|
||||
serviceByHandle.clear();
|
||||
handleByService.clear();
|
||||
for (ServiceCallback callback : serviceCallbacks.values()) {
|
||||
callback.onClose();
|
||||
}
|
||||
serviceCallbacks.clear();
|
||||
mSupport.createTransactionBuilder("open GFDI")
|
||||
.write(characteristicSend, registerService(Service.GFDI, false))
|
||||
.write(characteristicSend, registerService(Service.GFDI, mSupport.mlrEnabled()))
|
||||
.queue();
|
||||
break;
|
||||
case UNK_RESP:
|
||||
@@ -391,69 +390,95 @@ public class CommunicatorV2 implements ICommunicator {
|
||||
}
|
||||
}
|
||||
|
||||
private void processGfdi(final ByteBuffer message) {
|
||||
final byte[] partial = new byte[message.remaining()];
|
||||
message.get(partial);
|
||||
this.cobsCoDec.receivedBytes(partial);
|
||||
private static class GfdiCallback implements ServiceCallback {
|
||||
private final CobsCoDec cobsCoDec = new CobsCoDec();
|
||||
private final GarminSupport mSupport;
|
||||
|
||||
this.mSupport.onMessage(this.cobsCoDec.retrieveMessage());
|
||||
private GfdiCallback(final GarminSupport support) {
|
||||
this.mSupport = support;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(final byte[] value) {
|
||||
this.cobsCoDec.receivedBytes(value);
|
||||
this.mSupport.onMessage(this.cobsCoDec.retrieveMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void processRealtimeHeartRate(final ByteBuffer buf) {
|
||||
final byte type = buf.get(); // 0/2/3? 3 == realtime?
|
||||
final int hr = buf.get();
|
||||
final int resting = buf.get();
|
||||
// ff ff after
|
||||
LOG.debug("Got realtime HR: type={} hr={} resting={}", type, hr, resting);
|
||||
private class RealtimeHeartRateCallback implements ServiceCallback {
|
||||
@Override
|
||||
public void onMessage(final byte[] value) {
|
||||
final byte type = value[0]; // 0/2/3? 3 == realtime?
|
||||
final int hr = value[0] & 0xff;
|
||||
final int resting = value[0] & 0xff;
|
||||
// ff ff after
|
||||
LOG.debug("Got realtime HR: type={} hr={} resting={}", type, hr, resting);
|
||||
|
||||
if (hr > 0) {
|
||||
broadcastRealtimeActivity(hr, -1);
|
||||
if (hr > 0) {
|
||||
broadcastRealtimeActivity(hr, -1);
|
||||
|
||||
if (realtimeHrOneShot && realtimeHrHandle != 0) {
|
||||
onEnableRealtimeHeartRateMeasurement(false);
|
||||
if (realtimeHrOneShot && handleByService.containsKey(Service.REALTIME_HR)) {
|
||||
onEnableRealtimeHeartRateMeasurement(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processRealtimeSteps(final ByteBuffer buf) {
|
||||
final int steps = buf.getInt();
|
||||
final int goal = buf.getInt();
|
||||
LOG.debug("Got realtime steps: steps={} goal={}", steps, goal);
|
||||
private class RealtimeStepsCallback implements ServiceCallback {
|
||||
@Override
|
||||
public void onMessage(final byte[] value) {
|
||||
final int steps = BLETypeConversions.toUint32(value, 0);
|
||||
final int goal = BLETypeConversions.toUint32(value, 4);
|
||||
LOG.debug("Got realtime steps: steps={} goal={}", steps, goal);
|
||||
|
||||
if (previousSteps == -1) {
|
||||
previousSteps = steps;
|
||||
}
|
||||
|
||||
broadcastRealtimeActivity(-1, steps - previousSteps);
|
||||
|
||||
if (previousSteps == -1) {
|
||||
previousSteps = steps;
|
||||
}
|
||||
|
||||
broadcastRealtimeActivity(-1, steps - previousSteps);
|
||||
|
||||
previousSteps = steps;
|
||||
}
|
||||
|
||||
private void processRealtimeAccelerometer(final ByteBuffer message) {
|
||||
final byte[] partial = new byte[message.remaining()];
|
||||
message.get(partial);
|
||||
private static class RealtimeAccelerometerCallback implements ServiceCallback {
|
||||
@Override
|
||||
public void onConnect(final ServiceWriter writer) {
|
||||
writer.write("start realtime accel", new byte[]{0x01});
|
||||
}
|
||||
|
||||
LOG.debug("Got realtime accel: {}", GB.hexdump(partial));
|
||||
@Override
|
||||
public void onMessage(final byte[] value) {
|
||||
LOG.debug("Got realtime accel: {}", GB.hexdump(value));
|
||||
}
|
||||
}
|
||||
|
||||
private void processRealtimeSpo2(final ByteBuffer message) {
|
||||
final int spo2 = message.get(); // -1 when unknown, and the ts is not valid in that case
|
||||
final int garminTs = message.getInt();
|
||||
private static class RealtimeSpo2Callback implements ServiceCallback {
|
||||
@Override
|
||||
public void onMessage(final byte[] value) {
|
||||
final int spo2 = value[0]; // -1 when unknown, and the ts is not valid in that case
|
||||
final int garminTs = BLETypeConversions.toUint32(value, 1);
|
||||
|
||||
LOG.debug("Got realtime SpO2 at {}: {}", new Date(GarminTimeUtils.garminTimestampToJavaMillis(garminTs)), spo2);
|
||||
LOG.debug("Got realtime SpO2 at {}: {}", new Date(GarminTimeUtils.garminTimestampToJavaMillis(garminTs)), spo2);
|
||||
}
|
||||
}
|
||||
|
||||
private void processRealtimeRespiration(final ByteBuffer message) {
|
||||
final int breathsPerMinute = message.get(); // can be negative if unknown, usually -2
|
||||
private static class RealtimeRespirationCallback implements ServiceCallback {
|
||||
@Override
|
||||
public void onMessage(final byte[] value) {
|
||||
final int breathsPerMinute = value[0]; // can be negative if unknown, usually -2
|
||||
|
||||
LOG.debug("Got realtime respiration: {}", breathsPerMinute);
|
||||
LOG.debug("Got realtime respiration: {}", breathsPerMinute);
|
||||
}
|
||||
}
|
||||
|
||||
private void processRealtimeHrv(final ByteBuffer message) {
|
||||
final short rr = message.getShort();
|
||||
final int unk = message.getInt();
|
||||
|
||||
LOG.debug("Got realtime HRV: rr={}, unk={}", rr, unk);
|
||||
private static class RealtimeHrvCallback implements ServiceCallback {
|
||||
@Override
|
||||
public void onMessage(final byte[] value) {
|
||||
final int rr = BLETypeConversions.toUint16(value, 0);
|
||||
final int unk = BLETypeConversions.toUint32(value, 2);
|
||||
LOG.debug("Got realtime HRV: rr={}, unk={}", rr, unk);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] closeAllServices() {
|
||||
@@ -582,15 +607,25 @@ public class CommunicatorV2 implements ICommunicator {
|
||||
}
|
||||
|
||||
public interface ServiceCallback {
|
||||
void onConnect(ServiceWriter writer);
|
||||
void onClose();
|
||||
default void onConnect(ServiceWriter writer) {
|
||||
|
||||
}
|
||||
|
||||
default void onClose() {
|
||||
|
||||
}
|
||||
|
||||
void onMessage(byte[] value);
|
||||
}
|
||||
|
||||
public class ServiceWriter {
|
||||
public interface ServiceWriter {
|
||||
void write(String taskName, byte[] value);
|
||||
}
|
||||
|
||||
public class MlServiceWriter implements ServiceWriter {
|
||||
private final int handle;
|
||||
|
||||
private ServiceWriter(final int handle) {
|
||||
private MlServiceWriter(final int handle) {
|
||||
this.handle = handle;
|
||||
}
|
||||
|
||||
@@ -603,4 +638,24 @@ public class CommunicatorV2 implements ICommunicator {
|
||||
.queue();
|
||||
}
|
||||
}
|
||||
|
||||
private MlrCommunicator createMlrCommunicator(final int handle, final ServiceCallback callback) {
|
||||
final MlrCommunicator.MessageSender messageSender = (taskName, packet) -> mSupport.createTransactionBuilder(taskName)
|
||||
.write(characteristicSend, packet)
|
||||
.queue();
|
||||
|
||||
return new MlrCommunicator(handle, maxWriteSize, messageSender, callback::onMessage);
|
||||
}
|
||||
|
||||
public static class MlrServiceWriter implements ServiceWriter {
|
||||
private final MlrCommunicator mlrComm;
|
||||
|
||||
private MlrServiceWriter(final MlrCommunicator mlrComm) {
|
||||
this.mlrComm = mlrComm;
|
||||
}
|
||||
|
||||
public void write(final String taskName, final byte[] value) {
|
||||
mlrComm.sendMessage(taskName, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v2;
|
||||
|
||||
import android.bluetooth.BluetoothGatt;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedList;
|
||||
|
||||
/**
|
||||
* As per <a href="https://gadgetbridge.org/internals/specifics/garmin-protocol/#multi-link-reliable-protocol">the docs.</a>
|
||||
*/
|
||||
public class MlrCommunicator {
|
||||
private final Logger LOG;
|
||||
|
||||
public static final int MLR_FLAG_MASK = 0x80;
|
||||
public static final int HANDLE_MASK = 0x70;
|
||||
public static final int HANDLE_SHIFT = 4;
|
||||
public static final int REQ_NUM_MASK = 0x0F;
|
||||
public static final int SEQ_NUM_MASK = 0x3F;
|
||||
|
||||
private static final int MAX_SEQ_NUM = 0x3F;
|
||||
private static final int INITIAL_MAX_UNACKED_SEND = 0x20;
|
||||
private static final int MAX_RETRANSMISSION_TIMEOUT = 20000;
|
||||
private static final int INITIAL_RETRANSMISSION_TIMEOUT = 1000;
|
||||
private static final int ACK_TIMEOUT = 250;
|
||||
private static final int ACK_TRIGGER_THRESHOLD = 5;
|
||||
|
||||
private final int handle;
|
||||
private int maxPacketSize;
|
||||
|
||||
private int lastSendAck = 0x00;
|
||||
private int nextSendSeq = 0x00;
|
||||
private int nextRcvSeq = 0x00;
|
||||
private int lastRcvAck = 0x00;
|
||||
private int maxNumUnackedSend = INITIAL_MAX_UNACKED_SEND;
|
||||
private int retransmissionTimeout = INITIAL_RETRANSMISSION_TIMEOUT;
|
||||
|
||||
private final LinkedList<Fragment> fragmentQueue = new LinkedList<>();
|
||||
private final Fragment[] sentFragments = new Fragment[MAX_SEQ_NUM + 1];
|
||||
|
||||
private final Handler timeoutHandler = new Handler(Looper.getMainLooper());
|
||||
private final Runnable ackRunnable = this::sendAckPacket;
|
||||
private final Runnable retransmissionRunnable = this::onRetransmissionTimeout;
|
||||
|
||||
private final MessageSender messageSender;
|
||||
private final MessageReceiver messageReceiver;
|
||||
|
||||
public MlrCommunicator(final int handle,
|
||||
final int maxPacketSize,
|
||||
final MessageSender messageSender,
|
||||
final MessageReceiver messageReceiver) {
|
||||
this.LOG = LoggerFactory.getLogger(MlrCommunicator.class.getName() + "(" + handle + ")");
|
||||
this.handle = handle;
|
||||
this.maxPacketSize = maxPacketSize;
|
||||
this.messageSender = messageSender;
|
||||
this.messageReceiver = messageReceiver;
|
||||
}
|
||||
|
||||
public void setMaxPacketSize(final int maxPacketSize) {
|
||||
this.maxPacketSize = maxPacketSize;
|
||||
}
|
||||
|
||||
public void sendMessage(final String taskName, final byte[] message) {
|
||||
if (message == null || message.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.debug(
|
||||
"Queuing MLR message for '{}' ({} bytes)",
|
||||
taskName,
|
||||
message.length
|
||||
);
|
||||
|
||||
int remainingBytes = message.length;
|
||||
int i = 0;
|
||||
if (remainingBytes > maxPacketSize - 2) {
|
||||
int position = 0;
|
||||
while (remainingBytes > 0) {
|
||||
final byte[] fragment = Arrays.copyOfRange(message, position, position + Math.min(remainingBytes, maxPacketSize - 2));
|
||||
fragmentQueue.add(new Fragment(taskName, i++, fragment));
|
||||
position += fragment.length;
|
||||
remainingBytes -= fragment.length;
|
||||
}
|
||||
} else {
|
||||
fragmentQueue.add(new Fragment(taskName, 0, message));
|
||||
}
|
||||
|
||||
runProtocol();
|
||||
}
|
||||
|
||||
public void onPacketReceived(final byte[] packet) {
|
||||
if (packet.length < 2) {
|
||||
LOG.warn("MLR packet too short: {}", packet.length);
|
||||
return;
|
||||
}
|
||||
|
||||
// MLR header
|
||||
final int byte0 = packet[0] & 0xFF;
|
||||
final int byte1 = packet[1] & 0xFF;
|
||||
|
||||
if ((byte0 & MLR_FLAG_MASK) == 0) {
|
||||
LOG.error("Received non-MLR packet");
|
||||
return;
|
||||
}
|
||||
|
||||
final int packetHandle = (byte0 & HANDLE_MASK) >> HANDLE_SHIFT;
|
||||
final int reqNum = ((byte0 & REQ_NUM_MASK) << 2) | ((byte1 >> 6) & 0x03);
|
||||
final int seqNum = byte1 & SEQ_NUM_MASK;
|
||||
|
||||
if (packetHandle != (handle & 0x07)) {
|
||||
LOG.error("MLR packet for wrong handle: expected {}, got {}", handle & 0x07, packetHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.debug(
|
||||
"MLR packet received: reqNum={}, seqNum={}, dataLen={}",
|
||||
reqNum,
|
||||
seqNum,
|
||||
packet.length - 2
|
||||
);
|
||||
|
||||
// Process ACK if request number changed
|
||||
if (reqNum != lastRcvAck) {
|
||||
processAck(reqNum);
|
||||
}
|
||||
|
||||
// Process data if any, and in sequence
|
||||
if (packet.length > 2) {
|
||||
if (seqNum == nextRcvSeq) {
|
||||
// In-sequence packet
|
||||
final byte[] data = Arrays.copyOfRange(packet, 2, packet.length);
|
||||
try {
|
||||
messageReceiver.onDataReceived(data);
|
||||
} catch (final Exception e) {
|
||||
LOG.error("Receiver failed to handle MLR data", e);
|
||||
}
|
||||
|
||||
nextRcvSeq = (nextRcvSeq + 1) % (MAX_SEQ_NUM + 1);
|
||||
|
||||
scheduleAckIfNeeded();
|
||||
} else {
|
||||
LOG.warn("Out-of-sequence packet - expected {}, got {}", nextRcvSeq, seqNum);
|
||||
// Correct sequence will be retransmitted by sender
|
||||
// Regardless, re-send the expected ack since the sender shouldn't be sending these
|
||||
sendAckPacket();
|
||||
}
|
||||
}
|
||||
|
||||
runProtocol();
|
||||
}
|
||||
|
||||
private void processAck(final int reqNum) {
|
||||
final int numAcked = (reqNum - lastRcvAck + MAX_SEQ_NUM + 1) % (MAX_SEQ_NUM + 1);
|
||||
final int numUnacked = (nextSendSeq - lastRcvAck + MAX_SEQ_NUM + 1) % (MAX_SEQ_NUM + 1);
|
||||
|
||||
LOG.debug(
|
||||
"Processing ACK: reqNum={}, numAcked={}, numUnacked={}, will expire fragments [{}, {}]",
|
||||
reqNum,
|
||||
numAcked,
|
||||
numUnacked,
|
||||
lastRcvAck,
|
||||
reqNum - 1
|
||||
);
|
||||
|
||||
// Stop retransmission timer
|
||||
timeoutHandler.removeCallbacks(retransmissionRunnable);
|
||||
|
||||
// Remove acked messages from the array
|
||||
for (int i = lastRcvAck; i != reqNum; i = (i + 1) % (MAX_SEQ_NUM + 1)) {
|
||||
if (sentFragments[i] == null) {
|
||||
LOG.error("Attempting to expire null fragment at index {}", i);
|
||||
}
|
||||
sentFragments[i] = null;
|
||||
}
|
||||
|
||||
lastRcvAck = reqNum;
|
||||
|
||||
// Restart retransmission timer if there are still unacked packets
|
||||
if (lastRcvAck != nextSendSeq) {
|
||||
startRetransmissionTimer();
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleAckIfNeeded() {
|
||||
timeoutHandler.removeCallbacks(ackRunnable);
|
||||
|
||||
final int numRcvdUnacked = (nextRcvSeq - lastSendAck + MAX_SEQ_NUM + 1) % (MAX_SEQ_NUM + 1);
|
||||
if (numRcvdUnacked >= ACK_TRIGGER_THRESHOLD) {
|
||||
sendAckPacket();
|
||||
} else {
|
||||
timeoutHandler.postDelayed(ackRunnable, ACK_TIMEOUT);
|
||||
LOG.debug("Started ack timer: {}ms", ACK_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendAckPacket() {
|
||||
timeoutHandler.removeCallbacks(ackRunnable);
|
||||
|
||||
// Send ACK-only packet (no data)
|
||||
byte[] packet = createPacket(nextRcvSeq, 0, new byte[0]);
|
||||
messageSender.sendPacket("ack reqNum=" + nextRcvSeq, packet);
|
||||
lastSendAck = nextRcvSeq;
|
||||
LOG.debug("Sent ACK packet: reqNum={}", nextRcvSeq);
|
||||
}
|
||||
|
||||
private void runProtocol() {
|
||||
// Check if we can send more packets
|
||||
final int numSentUnacked = (nextSendSeq - lastRcvAck + MAX_SEQ_NUM + 1) % (MAX_SEQ_NUM + 1);
|
||||
|
||||
if (numSentUnacked >= maxNumUnackedSend) {
|
||||
LOG.debug("Cannot send more packets: {} unacked, max {}", numSentUnacked, maxNumUnackedSend);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send next fragment if available
|
||||
final Fragment fragment = fragmentQueue.poll();
|
||||
if (fragment != null) {
|
||||
final byte[] packet = createPacket(nextRcvSeq, nextSendSeq, fragment.data);
|
||||
messageSender.sendPacket(fragment.taskName + " (" + fragment.num + ")", packet);
|
||||
sentFragments[nextSendSeq] = fragment;
|
||||
|
||||
nextSendSeq = (nextSendSeq + 1) % (MAX_SEQ_NUM + 1);
|
||||
|
||||
// Start retransmission timer if this is the first unacked packet
|
||||
if (numSentUnacked == 0) {
|
||||
startRetransmissionTimer();
|
||||
}
|
||||
|
||||
LOG.debug("Sent MLR packet: seqNum={}, dataLen={}", (nextSendSeq - 1 + MAX_SEQ_NUM + 1) % (MAX_SEQ_NUM + 1), fragment.data.length);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] createPacket(final int reqNum, final int seqNum, final byte[] data) {
|
||||
byte[] packet = new byte[2 + data.length];
|
||||
|
||||
// First byte: MLR flag (1) + handle (3 bits) + reqNum high bits (4 bits)
|
||||
packet[0] = (byte) (MLR_FLAG_MASK | ((handle & 0x07) << HANDLE_SHIFT) | ((reqNum >> 2) & REQ_NUM_MASK));
|
||||
|
||||
// Second byte: reqNum low bits (2 bits) + seqNum (6 bits)
|
||||
packet[1] = (byte) (((reqNum & 0x03) << 6) | (seqNum & SEQ_NUM_MASK));
|
||||
|
||||
// Data
|
||||
System.arraycopy(data, 0, packet, 2, data.length);
|
||||
|
||||
return packet;
|
||||
}
|
||||
|
||||
private void startRetransmissionTimer() {
|
||||
timeoutHandler.removeCallbacks(retransmissionRunnable);
|
||||
timeoutHandler.postDelayed(retransmissionRunnable, retransmissionTimeout);
|
||||
|
||||
LOG.debug("Started retransmission timer: {}ms", retransmissionTimeout);
|
||||
}
|
||||
|
||||
private void onRetransmissionTimeout() {
|
||||
LOG.debug("Retransmission timeout expired");
|
||||
|
||||
// Backoff retransmission timeout and reduce the maximum unacked
|
||||
retransmissionTimeout = Math.min(retransmissionTimeout * 2, MAX_RETRANSMISSION_TIMEOUT);
|
||||
maxNumUnackedSend = Math.max(1, maxNumUnackedSend / 2);
|
||||
|
||||
LOG.debug(
|
||||
"Retransmission: timeout={}ms, maxUnacked={}, will re-send fragments [{}, {}]",
|
||||
retransmissionTimeout,
|
||||
maxNumUnackedSend,
|
||||
lastRcvAck,
|
||||
nextSendSeq - 1
|
||||
);
|
||||
|
||||
for (int i = lastRcvAck; i != nextSendSeq; i = (i + 1) % (MAX_SEQ_NUM + 1)) {
|
||||
LOG.debug("Re-sending fragment {}", i);
|
||||
final Fragment fragment = sentFragments[i];
|
||||
if (fragment == null) {
|
||||
LOG.error("Attempting to re-send null fragment at index {}", i);
|
||||
continue;
|
||||
}
|
||||
final byte[] packet = createPacket(nextRcvSeq, i, fragment.data);
|
||||
messageSender.sendPacket("retransmission " + fragment.taskName + " (" + fragment.num + ")", packet);
|
||||
}
|
||||
|
||||
startRetransmissionTimer();
|
||||
}
|
||||
|
||||
public void close() {
|
||||
LOG.debug("Closing MLR communicator");
|
||||
|
||||
timeoutHandler.removeCallbacksAndMessages(null);
|
||||
|
||||
fragmentQueue.clear();
|
||||
}
|
||||
|
||||
public void onConnectionStateChange(final BluetoothGatt gatt, final int status, final int newState) {
|
||||
if (newState != BluetoothGatt.STATE_CONNECTED) {
|
||||
timeoutHandler.removeCallbacksAndMessages(null);
|
||||
}
|
||||
}
|
||||
|
||||
public interface MessageSender {
|
||||
void sendPacket(String taskName, byte[] packet);
|
||||
}
|
||||
|
||||
public interface MessageReceiver {
|
||||
void onDataReceived(final byte[] data);
|
||||
}
|
||||
|
||||
private record Fragment(String taskName, int num, byte[] data) {
|
||||
}
|
||||
}
|
||||
+1
-14
@@ -21,7 +21,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||
public abstract class GFDIMessage {
|
||||
protected static final Logger LOG = LoggerFactory.getLogger(GFDIMessage.class);
|
||||
private static int maxPacketSize = 375; //safe default?
|
||||
protected final ByteBuffer response = ByteBuffer.allocate(1000);
|
||||
protected final ByteBuffer response = ByteBuffer.allocate(10 * 1024); // FIXME we should allocate the minimum necessary for each message
|
||||
protected GFDIStatusMessage statusMessage;
|
||||
protected GarminMessage garminMessage;
|
||||
|
||||
@@ -131,15 +131,6 @@ public abstract class GFDIMessage {
|
||||
this.objectClass = objectClass;
|
||||
}
|
||||
|
||||
public static Class<? extends GFDIMessage> getClassFromId(final int id) {
|
||||
for (final GarminMessage garminMessage : GarminMessage.values()) {
|
||||
if (garminMessage.getId() == id) {
|
||||
return garminMessage.getObjectClass();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static GarminMessage fromId(final int id) {
|
||||
for (final GarminMessage garminMessage : GarminMessage.values()) {
|
||||
@@ -153,10 +144,6 @@ public abstract class GFDIMessage {
|
||||
public int getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
private Class<? extends GFDIMessage> getObjectClass() {
|
||||
return objectClass;
|
||||
}
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
|
||||
@@ -4354,4 +4354,6 @@
|
||||
<string name="recovery_heart_rate">Recovery Heart rate</string>
|
||||
<string name="huawei_send_gps_and_time_title">Send GPS and Time to device</string>
|
||||
<string name="huawei_send_gps_and_time_summary">Send GPS and Time to device for weather and search stars optimization. Location should be set in the settings</string>
|
||||
<string name="garmin_mlr_protocol">Multi-Link Reliable Protocol</string>
|
||||
<string name="feature_experimental_unstable">Experimental, might be unstable</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="false"
|
||||
android:icon="@drawable/ic_bolt"
|
||||
android:key="garmin_mlr"
|
||||
android:layout="@layout/preference_checkbox"
|
||||
android:summary="@string/feature_experimental_unstable"
|
||||
android:title="@string/garmin_mlr_protocol" />
|
||||
</androidx.preference.PreferenceScreen>
|
||||
Reference in New Issue
Block a user