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 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 @@ + + + + + + + + + + + + + + +