diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoEncoAirCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoEncoAirCoordinator.java
new file mode 100644
index 000000000..5546e24fd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoEncoAirCoordinator.java
@@ -0,0 +1,108 @@
+/* Copyright (C) 2024 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.devices.oppo;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLClassicDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.OppoHeadphonesSupport;
+
+public class OppoEncoAirCoordinator extends AbstractBLClassicDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("OPPO Enco Air", Pattern.LITERAL);
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull final GBDevice gbDevice, @NonNull final Device device, @NonNull final DaoSession session) throws GBException {
+
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Oppo";
+ }
+
+ @NonNull
+ @Override
+ public Class extends DeviceSupport> getDeviceSupportClass() {
+ return OppoHeadphonesSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_oppo_enco_air;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_nothingear;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_nothingear_disabled;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 3;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ final BatteryConfig battery1 = new BatteryConfig(0, R.drawable.ic_nothing_ear_l, R.string.left_earbud);
+ final BatteryConfig battery2 = new BatteryConfig(1, R.drawable.ic_nothing_ear_r, R.string.right_earbud);
+ final BatteryConfig battery3 = new BatteryConfig(2, R.drawable.ic_tws_case, R.string.battery_case);
+ return new BatteryConfig[]{battery1, battery2, battery3};
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings settings = new DeviceSpecificSettings();
+
+ settings.addRootScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS);
+ settings.addSubScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS, R.xml.devicesettings_oppo_headphones_touch_options);
+
+ settings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS);
+ settings.addSubScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS, R.xml.devicesettings_headphones);
+
+ return settings;
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new OppoHeadphonesSettingsCustomizer();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesPreferences.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesPreferences.java
new file mode 100644
index 000000000..c5538e8cd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesPreferences.java
@@ -0,0 +1,33 @@
+/* Copyright (C) 2024 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.devices.oppo;
+
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigSide;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigType;
+
+public class OppoHeadphonesPreferences {
+ public static String getKey(final TouchConfigSide side, final TouchConfigType type) {
+ return String.format(
+ Locale.ROOT,
+ "oppo_touch__%s__%s",
+ side.name().toLowerCase(Locale.ROOT),
+ type.name().toLowerCase(Locale.ROOT)
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesSettingsCustomizer.java
new file mode 100644
index 000000000..693a356e7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesSettingsCustomizer.java
@@ -0,0 +1,71 @@
+/* Copyright (C) 2024 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.devices.oppo;
+
+import android.os.Parcel;
+
+import androidx.preference.Preference;
+
+import java.util.Collections;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigSide;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigType;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class OppoHeadphonesSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ public static final Creator CREATOR = new Creator() {
+ @Override
+ public OppoHeadphonesSettingsCustomizer createFromParcel(final Parcel in) {
+ return new OppoHeadphonesSettingsCustomizer();
+ }
+
+ @Override
+ public OppoHeadphonesSettingsCustomizer[] newArray(final int size) {
+ return new OppoHeadphonesSettingsCustomizer[size];
+ }
+ };
+
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ for (final TouchConfigSide side : TouchConfigSide.values()) {
+ for (final TouchConfigType type : TouchConfigType.values()) {
+ handler.addPreferenceHandlerFor(OppoHeadphonesPreferences.getKey(side, type));
+ }
+ }
+ }
+
+ @Override
+ public Set getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
index fdeb23950..fc98d6621 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
@@ -224,6 +224,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.nothing.Ear1Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.nothing.Ear2Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.nothing.EarStickCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.nut.NutCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoEncoAirCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.pinetime.PineTimeJFCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.qc35.QC35Coordinator;
@@ -538,6 +539,7 @@ public enum DeviceType {
FLIPPER_ZERO(FlipperZeroCoordinator.class),
SUPER_CARS(SuperCarsCoordinator.class),
ASTEROIDOS(AsteroidOSDeviceCoordinator.class),
+ OPPO_ENCO_AIR(OppoEncoAirCoordinator.class),
SOFLOW_SO6(SoFlowCoordinator.class),
WITHINGS_STEEL_HR(WithingsSteelHRDeviceCoordinator.class),
SONY_WENA_3(SonyWena3Coordinator.class),
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractHeadphoneDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractHeadphoneDeviceSupport.java
index 90717be79..40c94f871 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractHeadphoneDeviceSupport.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractHeadphoneDeviceSupport.java
@@ -108,7 +108,6 @@ public abstract class AbstractHeadphoneDeviceSupport extends AbstractSerialDevic
@Override
public void onSendConfiguration(String config) {
- LOG.warn("ONSENDCONFIGURATION");
if (PREF_SPEAK_NOTIFICATIONS_FOCUS_EXCLUSIVE.equals(config)) {
final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress());
gbTextToSpeech.setAudioFocus(prefs.getBoolean(PREF_SPEAK_NOTIFICATIONS_FOCUS_EXCLUSIVE, false) ?
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/OppoHeadphonesIoThread.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/OppoHeadphonesIoThread.java
new file mode 100644
index 000000000..a67f6677e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/OppoHeadphonesIoThread.java
@@ -0,0 +1,75 @@
+/* Copyright (C) 2024 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.service.devices.oppo;
+
+import static nodomain.freeyourgadget.gadgetbridge.util.GB.hexdump;
+
+import android.bluetooth.BluetoothAdapter;
+import android.content.Context;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.btclassic.BtClassicIoThread;
+import nodomain.freeyourgadget.gadgetbridge.service.serial.AbstractSerialDeviceSupport;
+
+public class OppoHeadphonesIoThread extends BtClassicIoThread {
+ private static final Logger LOG = LoggerFactory.getLogger(OppoHeadphonesIoThread.class);
+
+ private final OppoHeadphonesProtocol mProtocol;
+
+ public OppoHeadphonesIoThread(final GBDevice gbDevice,
+ final Context context,
+ final OppoHeadphonesProtocol deviceProtocol,
+ final AbstractSerialDeviceSupport deviceSupport,
+ final BluetoothAdapter btAdapter) {
+ super(gbDevice, context, deviceProtocol, deviceSupport, btAdapter);
+ this.mProtocol = deviceProtocol;
+ }
+
+ @NonNull
+ @Override
+ protected UUID getUuidToConnect(@NonNull final ParcelUuid[] uuids) {
+ return UUID.fromString("0000079a-d102-11e1-9b23-00025b00a5a5");
+ }
+
+ @Override
+ protected void initialize() {
+ write(mProtocol.encodeFirmwareVersionReq());
+ write(mProtocol.encodeConfigurationReq());
+ write(mProtocol.encodeBatteryReq());
+ setUpdateState(GBDevice.State.INITIALIZED);
+ }
+
+ @Override
+ protected byte[] parseIncoming(final InputStream inStream) throws IOException {
+ final byte[] buffer = new byte[1048576]; //HUGE read
+ final int bytes = inStream.read(buffer);
+ // FIXME: We should buffer this and handle partial commands
+ LOG.debug("Read {} bytes: {}", bytes, hexdump(buffer, 0, bytes));
+ return Arrays.copyOf(buffer, bytes);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/OppoHeadphonesProtocol.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/OppoHeadphonesProtocol.java
new file mode 100644
index 000000000..89d9c0cca
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/OppoHeadphonesProtocol.java
@@ -0,0 +1,343 @@
+/* Copyright (C) 2024 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.service.devices.oppo;
+
+import org.apache.commons.lang3.ArrayUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo;
+import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoHeadphonesPreferences;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryState;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.OppoCommand;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigSide;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigValue;
+import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.preferences.DevicePrefs;
+
+public class OppoHeadphonesProtocol extends GBDeviceProtocol {
+ private static final Logger LOG = LoggerFactory.getLogger(OppoHeadphonesProtocol.class);
+
+ public static final byte CMD_PREAMBLE = (byte) 0xaa;
+
+ private int seqNum = 0;
+
+ protected OppoHeadphonesProtocol(final GBDevice device) {
+ super(device);
+ }
+
+ @Override
+ public GBDeviceEvent[] decodeResponse(final byte[] responseData) {
+ final List events = new ArrayList<>();
+ int i = 0;
+ while (i < responseData.length) {
+ if (responseData[i] != CMD_PREAMBLE) {
+ LOG.warn("Unexpected preamble {}", responseData[i]);
+ i++;
+ continue;
+ }
+ final int totalLength = BLETypeConversions.toUint16(responseData, i + 1);
+ if (responseData.length - i < totalLength + 2) {
+ LOG.error("Got partial response with {} bytes, expected {}", responseData.length - i, totalLength + 2);
+ break;
+ }
+
+ final byte[] singleResponse = ArrayUtils.subarray(responseData, i, i + totalLength + 3);
+
+ events.addAll(handleSingleResponse(singleResponse));
+
+ i += totalLength + 2;
+ }
+ return events.toArray(new GBDeviceEvent[0]);
+ }
+
+ private static List handleSingleResponse(final byte[] responseData) {
+ final List events = new ArrayList<>();
+
+ final ByteBuffer responseBuf = ByteBuffer.wrap(responseData).order(ByteOrder.LITTLE_ENDIAN);
+ final byte preamble = responseBuf.get();
+
+ if (preamble != CMD_PREAMBLE) {
+ LOG.error("Unexpected preamble {}", preamble);
+ return Collections.emptyList();
+ }
+
+ final byte totalLength = responseBuf.get();
+ if (responseData.length != totalLength + 2) {
+ LOG.error("Invalid number of bytes {}, expected {}", responseData.length, totalLength + 2);
+ return Collections.emptyList();
+ }
+
+ final short zero = responseBuf.getShort();
+ if (zero != 0) {
+ LOG.error("Unexpected bytes: {}, expected 0", zero);
+ return Collections.emptyList();
+ }
+
+ final short code = responseBuf.getShort();
+ final OppoCommand command = OppoCommand.fromCode(code);
+ if (command == null) {
+ LOG.warn("Unknown command code {}", String.format(Locale.ROOT, "0x%04x", code));
+ return Collections.emptyList();
+ }
+
+ final int seq = responseBuf.get();
+ final short payloadLength = responseBuf.getShort();
+ final byte[] payload = new byte[payloadLength];
+ responseBuf.get(payload);
+
+ switch (command) {
+ case BATTERY_RET: {
+ if (payload[0] != 0) {
+ LOG.error("Unknown battery ret {}", payload[0]);
+ break;
+ }
+ events.addAll(parseBattery(payload));
+ break;
+ }
+ case DEVICE_INFO: {
+ switch (payload[0]) {
+ case 1: // battery
+ events.addAll(parseBattery(payload));
+ break;
+ case 2: // status
+ LOG.debug("Got status");
+ // TODO handle
+ break;
+ default:
+ LOG.warn("Unknown device info {}", payload[0]);
+ }
+
+ break;
+ }
+ case FIRMWARE_RET: {
+ if (payload[0] != 0) {
+ LOG.warn("Unexpected firmware ret {}", payload[0]);
+ break;
+ }
+
+ final String fwString = StringUtils.untilNullTerminator(payload, 1);
+ if (fwString == null) {
+ LOG.warn("Failed to get firmware string");
+ break;
+ }
+ final String[] parts = fwString.split(",");
+ if (parts.length % 3 != 0) {
+ LOG.warn("Fw parts length {} from '{}' is not divisible by 3", parts.length, fwString);
+ break;
+ }
+ final String[] fwVersionParts = new String[3];
+ for (int i = 0; i < parts.length; i += 3) {
+ final String versionPart = parts[i];
+ final String versionType = parts[i + 1];
+ final String version = parts[i + 2];
+ if (!"2".equals(versionType)) {
+ continue; // not fw
+ }
+
+ switch (versionPart) {
+ case "1":
+ fwVersionParts[0] = version;
+ break;
+ case "2":
+ fwVersionParts[1] = version;
+ break;
+ case "3":
+ fwVersionParts[2] = version;
+ break;
+ default:
+ LOG.warn("Unknown firmware version part {}", versionPart);
+ }
+ }
+
+ final String fwVersion = String.join(".", fwVersionParts);
+
+ final GBDeviceEventVersionInfo eventVersionInfo = new GBDeviceEventVersionInfo();
+ eventVersionInfo.fwVersion = fwVersion;
+ eventVersionInfo.hwVersion = GBApplication.getContext().getString(R.string.n_a);
+ events.add(eventVersionInfo);
+
+ LOG.debug("Got fw version: {}", fwVersion);
+
+ break;
+ }
+ case FIND_DEVICE_ACK: {
+ LOG.debug("Got find device ack, status={}", payload[0]);
+ break;
+ }
+ case TOUCH_CONFIG_RET: {
+ if (payload[0] != 0) {
+ LOG.warn("Unknown config ret {}", payload[0]);
+ break;
+ }
+ if ((payload.length - 2) % 4 != 0) {
+ LOG.warn("Unexpected config ret payload size {}", payload.length);
+ break;
+ }
+
+ final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences();
+
+ for (int i = 2; i < payload.length; i += 4) {
+ final int sideCode = payload[i] & 0xff;
+ final int typeCode = BLETypeConversions.toUint16(payload, i + 1);
+ final int valueCode = payload[i + 3] & 0xff;
+ final TouchConfigSide side = TouchConfigSide.fromCode(sideCode);
+ final TouchConfigType type = TouchConfigType.fromCode(typeCode);
+ final TouchConfigValue value = TouchConfigValue.fromCode(valueCode);
+
+ if (side == null) {
+ LOG.warn("Unknown side code {}", sideCode);
+ continue;
+ }
+ if (type == null) {
+ LOG.warn("Unknown type code {}", typeCode);
+ continue;
+ }
+ if (value == null) {
+ LOG.warn("Unknown value code {}", valueCode);
+ continue;
+ }
+
+ LOG.debug("Got touch config for {} {} = {}", side, type, value);
+
+ eventUpdatePreferences.withPreference(
+ OppoHeadphonesPreferences.getKey(side, type),
+ value.name().toLowerCase(Locale.ROOT)
+ );
+ }
+
+ events.add(eventUpdatePreferences);
+
+ break;
+ }
+ case TOUCH_CONFIG_ACK: {
+ LOG.debug("Got config ack, status={}", payload[0]);
+ break;
+ }
+ default:
+ LOG.warn("Unhandled command {}", command);
+ }
+
+ return events;
+ }
+
+ private static List parseBattery(final byte[] payload) {
+ final List events = new ArrayList<>();
+
+ final int numBatteries = payload[1] & 0xff;
+ for (int i = 2; i < payload.length; i += 2) {
+ if ((payload[i] & 0xff) == 0xff) {
+ continue;
+ }
+ final int batteryIndex = payload[i] - 1;
+ if (batteryIndex < 0 || batteryIndex > 2) {
+ LOG.error("Unknown battery index {}", payload[i]);
+ break;
+ }
+
+ final int batteryLevel = payload[i + 1] & 0x7f;
+ final BatteryState batteryState = (payload[i + 1] & 0x80) != 0 ? BatteryState.BATTERY_CHARGING : BatteryState.BATTERY_NORMAL;
+
+ LOG.debug("Got battery {}: {}%, {}", batteryIndex, batteryLevel, batteryState);
+
+ final GBDeviceEventBatteryInfo eventBatteryInfo = new GBDeviceEventBatteryInfo();
+ eventBatteryInfo.batteryIndex = batteryIndex;
+ eventBatteryInfo.level = batteryLevel;
+ eventBatteryInfo.state = batteryState;
+ events.add(eventBatteryInfo);
+ }
+
+ return events;
+ }
+
+ @Override
+ public byte[] encodeFirmwareVersionReq() {
+ return encodeMessage(OppoCommand.FIRMWARE_GET, new byte[0]);
+ }
+
+ @Override
+ public byte[] encodeFindDevice(final boolean start) {
+ return encodeMessage(OppoCommand.FIND_DEVICE_REQ, new byte[]{(byte) (start ? 0x01 : 0x00)});
+ }
+
+ @Override
+ public byte[] encodeSendConfiguration(final String config) {
+ final DevicePrefs prefs = getDevicePrefs();
+
+ if (config.startsWith("oppo_touch__")) {
+ final String[] parts = config.split("__");
+ final TouchConfigSide side = TouchConfigSide.valueOf(parts[1].toUpperCase(Locale.ROOT));
+ final TouchConfigType type = TouchConfigType.valueOf(parts[2].toUpperCase(Locale.ROOT));
+ final String valueCode = prefs.getString(OppoHeadphonesPreferences.getKey(side, type), null);
+ if (valueCode == null) {
+ LOG.warn("Failed to get touch option value for {}/{}", side, type);
+ return super.encodeSendConfiguration(config);
+ }
+
+ final TouchConfigValue value = TouchConfigValue.valueOf(valueCode.toUpperCase(Locale.ROOT));
+
+ LOG.debug("Sending {} {} = {}", side, type, value);
+
+ final ByteBuffer buf = ByteBuffer.allocate(5).order(ByteOrder.LITTLE_ENDIAN);
+ buf.put((byte) 0x01);
+ buf.put((byte) side.getCode());
+ buf.putShort((short) type.getCode());
+ buf.put((byte) value.getCode());
+
+ return encodeMessage(OppoCommand.TOUCH_CONFIG_SET, buf.array());
+ }
+
+ return super.encodeSendConfiguration(config);
+ }
+
+ public byte[] encodeBatteryReq() {
+ return encodeMessage(OppoCommand.BATTERY_REQ, new byte[0]);
+ }
+
+ public byte[] encodeConfigurationReq() {
+ return encodeMessage(OppoCommand.TOUCH_CONFIG_REQ, new byte[]{0x02, 0x03, 0x01});
+ }
+
+ private byte[] encodeMessage(final OppoCommand command, final byte[] payload) {
+ final ByteBuffer buf = ByteBuffer.allocate(9 + payload.length).order(ByteOrder.LITTLE_ENDIAN);
+ buf.put(CMD_PREAMBLE);
+ buf.put((byte) (buf.limit() - 2));
+ buf.put((byte) 0);
+ buf.put((byte) 0);
+ buf.putShort(command.getCode());
+ buf.put((byte) seqNum++);
+ buf.putShort((short) payload.length);
+ buf.put(payload);
+ return buf.array();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/OppoHeadphonesSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/OppoHeadphonesSupport.java
new file mode 100644
index 000000000..2e391ec20
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/OppoHeadphonesSupport.java
@@ -0,0 +1,44 @@
+/* Copyright (C) 2024 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.service.devices.oppo;
+
+import nodomain.freeyourgadget.gadgetbridge.service.AbstractHeadphoneDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceIoThread;
+import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol;
+
+public class OppoHeadphonesSupport extends AbstractHeadphoneDeviceSupport {
+ @Override
+ protected GBDeviceProtocol createDeviceProtocol() {
+ return new OppoHeadphonesProtocol(getDevice());
+ }
+
+ @Override
+ protected GBDeviceIoThread createDeviceIOThread() {
+ return new OppoHeadphonesIoThread(
+ getDevice(),
+ getContext(),
+ (OppoHeadphonesProtocol) getDeviceProtocol(),
+ OppoHeadphonesSupport.this,
+ getBluetoothAdapter()
+ );
+ }
+
+ @Override
+ public boolean useAutoConnect() {
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/commands/OppoCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/commands/OppoCommand.java
new file mode 100644
index 000000000..0d594c835
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/commands/OppoCommand.java
@@ -0,0 +1,55 @@
+/* Copyright (C) 2024 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands;
+
+import androidx.annotation.Nullable;
+
+public enum OppoCommand {
+ BATTERY_REQ(0x0106),
+ BATTERY_RET(0x8106),
+ DEVICE_INFO(0x0204),
+ FIRMWARE_GET(0x0105),
+ FIRMWARE_RET(0x8105),
+ TOUCH_CONFIG_REQ(0x0108),
+ TOUCH_CONFIG_SET(0x0401),
+ TOUCH_CONFIG_RET(0x8108),
+ TOUCH_CONFIG_ACK(0x8401),
+ FIND_DEVICE_REQ(0x0400),
+ FIND_DEVICE_ACK(0x8400),
+ ;
+
+ private final short code;
+
+ OppoCommand(final int code) {
+ this.code = (short) code;
+ }
+
+ public short getCode() {
+ return code;
+ }
+
+ @Nullable
+ public static OppoCommand fromCode(final short code) {
+ for (final OppoCommand cmd : OppoCommand.values()) {
+ if (cmd.code == code) {
+ return cmd;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/commands/TouchConfigSide.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/commands/TouchConfigSide.java
new file mode 100644
index 000000000..32c962b39
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/commands/TouchConfigSide.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2024 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands;
+
+import androidx.annotation.Nullable;
+
+public enum TouchConfigSide {
+ LEFT(0x01),
+ RIGHT(0x02),
+ ;
+
+ private final int code;
+
+ TouchConfigSide(final int code) {
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ @Nullable
+ public static TouchConfigSide fromCode(final int code) {
+ for (final TouchConfigSide param : TouchConfigSide.values()) {
+ if (param.code == code) {
+ return param;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/commands/TouchConfigType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/commands/TouchConfigType.java
new file mode 100644
index 000000000..a47024e9d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/commands/TouchConfigType.java
@@ -0,0 +1,48 @@
+/* Copyright (C) 2024 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands;
+
+import androidx.annotation.Nullable;
+
+public enum TouchConfigType {
+ UNK_1(0x0101),
+ TAP_2(0x0201),
+ TAP_3(0x0301),
+ HOLD(0x0401),
+ ;
+
+ private final int code;
+
+ TouchConfigType(final int code) {
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ @Nullable
+ public static TouchConfigType fromCode(final int code) {
+ for (final TouchConfigType param : TouchConfigType.values()) {
+ if (param.code == code) {
+ return param;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/commands/TouchConfigValue.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/commands/TouchConfigValue.java
new file mode 100644
index 000000000..ce49e1e46
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/commands/TouchConfigValue.java
@@ -0,0 +1,52 @@
+/* Copyright (C) 2024 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands;
+
+import androidx.annotation.Nullable;
+
+public enum TouchConfigValue {
+ OFF(0x00),
+ PLAY_PAUSE(0x01),
+ VOICE_ASSISTANT(0x03),
+ PREVIOUS(0x05),
+ NEXT(0x06),
+ GAME_MODE(0x11),
+ VOLUME_UP(0x0B),
+ VOLUME_DOWN(0x0C),
+ ;
+
+ private final int code;
+
+ TouchConfigValue(final int code) {
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ @Nullable
+ public static TouchConfigValue fromCode(final int code) {
+ for (final TouchConfigValue param : TouchConfigValue.values()) {
+ if (param.code == code) {
+ return param;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
index 1621c8895..945ad0a8c 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -3566,6 +3566,46 @@
- as_off
+
+ - @string/sony_button_mode_off
+ - @string/moondrop_touch_action_play_pause
+ - @string/pref_media_previous
+ - @string/pref_media_next
+ - @string/pref_title_touch_voice_assistant
+
+
+
+ - off
+ - play_pause
+ - previous
+ - next
+ - voice_assistant
+
+
+
+ - @string/sony_button_mode_off
+ - @string/pref_title_touch_voice_assistant
+ - @string/prefs_game_mode
+
+
+
+ - off
+ - voice_assistant
+ - game_mode
+
+
+
+ - @string/sony_button_mode_off
+ - @string/pref_media_volumeup
+ - @string/pref_media_volumedown
+
+
+
+ - off
+ - volume_up
+ - volume_down
+
+
- @string/pref_media_volumedown
- @string/pref_media_volumeup
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2260f939f..74f941a89 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2484,6 +2484,7 @@
CMF Buds Pro 2
CMF Watch Pro
CMF Watch Pro 2
+ Oppo Enco Air
Galaxy Buds
Galaxy Buds Live
Galaxy Buds Pro
diff --git a/app/src/main/res/xml/devicesettings_oppo_headphones_touch_options.xml b/app/src/main/res/xml/devicesettings_oppo_headphones_touch_options.xml
new file mode 100644
index 000000000..7c8c59b4f
--- /dev/null
+++ b/app/src/main/res/xml/devicesettings_oppo_headphones_touch_options.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+