From b8f5440a598a614a2bf139e2d2af4da06fa0b85d Mon Sep 17 00:00:00 2001 From: duhow Date: Sat, 16 May 2026 22:11:02 +0200 Subject: [PATCH] Anker: Add new device Soundcore Sport X20 (#5987) This is an almost full implementation for [Anker](https://gadgetbridge.org/gadgets/headphones/anker/) [Soundcore Sport X20](https://www.soundcore.com/products/a3968-sport-x20-earbuds) earbuds. Provides: - Firmware version and serial number - Display battery life when sent by earbuds - ANC Settings - Find Device - Equalizer with Presets - 3D Surrounding Sound - Toggle "Touch Tone" (sound when button is pressed) - Set the behavior of button actions - Set Auto Power-Off Timer - Enable discovery/pairing to add a second device to use the earbuds Reviewed-on: https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/5987 --- .../DeviceSettingsPreferenceConst.java | 2 + .../DeviceSpecificSettingsFragment.java | 1 + .../SoundcoreSportX20Coordinator.java | 91 +++++ .../SoundcoreSportX20SettingsCustomizer.java | 99 +++++ .../gadgetbridge/model/DeviceType.java | 2 + .../SoundcoreSportX20DeviceSupport.java | 27 ++ .../sport_x20/SoundcoreSportX20Protocol.java | 379 ++++++++++++++++++ .../soundcore/sport_x20/TapAction.java | 18 + .../soundcore/sport_x20/TapFunction.java | 23 ++ app/src/main/res/values/arrays.xml | 114 ++++++ app/src/main/res/values/strings.xml | 31 ++ .../devicesettings_soundcore_sport_x20.xml | 76 ++++ ...vicesettings_soundcore_sport_x20_audio.xml | 129 ++++++ ...ings_soundcore_sport_x20_touch_options.xml | 59 +++ 14 files changed, 1051 insertions(+) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/sport_x20/SoundcoreSportX20Coordinator.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/sport_x20/SoundcoreSportX20SettingsCustomizer.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/sport_x20/SoundcoreSportX20DeviceSupport.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/sport_x20/SoundcoreSportX20Protocol.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/sport_x20/TapAction.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/sport_x20/TapFunction.java create mode 100644 app/src/main/res/xml/devicesettings_soundcore_sport_x20.xml create mode 100644 app/src/main/res/xml/devicesettings_soundcore_sport_x20_audio.xml create mode 100644 app/src/main/res/xml/devicesettings_soundcore_sport_x20_touch_options.xml diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java index 9d47b30db6..0242d425f1 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java @@ -520,6 +520,8 @@ public class DeviceSettingsPreferenceConst { public static final String PREF_SOUNDCORE_VOICE_PROMPTS = "pref_soundcore_voice_prompts"; public static final String PREF_SOUNDCORE_BUTTON_BRIGHTNESS = "pref_soundcore_button_brightness"; public static final String PREF_SOUNDCORE_AUTO_POWER_OFF = "pref_soundcore_auto_power_off"; + public static final String PREF_SOUNDCORE_3D_SURROUND = "pref_soundcore_3d_surround"; + public static final String PREF_SOUNDCORE_ENABLE_PAIRING_MODE = "pref_soundcore_enable_pairing_mode"; public static final String PREF_SOUNDCORE_LDAC_MODE = "pref_soundcore_ldac_mode"; public static final String PREF_SOUNDCORE_GAMING_MODE = "pref_soundcore_gaming_mode"; public static final String PREF_SOUNDCORE_ADAPTIVE_DIRECTION = "pref_soundcore_adaptive_direction"; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java index bc850c5fc0..2537ddeeae 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java @@ -910,6 +910,7 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i addPreferenceHandlerFor(PREF_SOUNDCORE_VOICE_PROMPTS); addPreferenceHandlerFor(PREF_SOUNDCORE_BUTTON_BRIGHTNESS); addPreferenceHandlerFor(PREF_SOUNDCORE_AUTO_POWER_OFF); + addPreferenceHandlerFor(PREF_SOUNDCORE_3D_SURROUND); addPreferenceHandlerFor(PREF_SOUNDCORE_LDAC_MODE); addPreferenceHandlerFor(PREF_SOUNDCORE_GAMING_MODE); addPreferenceHandlerFor(PREF_SOUNDCORE_ADAPTIVE_DIRECTION); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/sport_x20/SoundcoreSportX20Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/sport_x20/SoundcoreSportX20Coordinator.java new file mode 100644 index 0000000000..803de409f4 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/sport_x20/SoundcoreSportX20Coordinator.java @@ -0,0 +1,91 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.soundcore.sport_x20; + +import androidx.annotation.NonNull; + +import java.util.regex.Pattern; + +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.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig; +import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.soundcore.sport_x20.SoundcoreSportX20DeviceSupport; + +public class SoundcoreSportX20Coordinator extends AbstractBLClassicDeviceCoordinator { + @Override + public int getDeviceNameResource() { + return R.string.devicetype_soundcore_sport_x20; + } + + @Override + public int getDefaultIconResource() { + return R.drawable.ic_device_galaxy_buds; + } + + @Override + public String getManufacturer() { + return "Anker"; + } + + @Override + protected Pattern getSupportedDeviceName() { + return Pattern.compile("soundcore Sport X20"); + } + + @Override + public int getBondingStyle() { + return BONDING_STYLE_NONE; + } + + @Override + public int getBatteryCount(final GBDevice device) { + return 3; + } + + @Override + public boolean supportsFindDevice(@NonNull final GBDevice device) { + return true; + } + + @Override + public BatteryConfig[] getBatteryConfig(final GBDevice device) { + final BatteryConfig battery1 = new BatteryConfig(0, R.drawable.ic_buds_pro_case, R.string.battery_case); + final BatteryConfig battery2 = new BatteryConfig(1, R.drawable.ic_nothing_ear_l, R.string.left_earbud); + final BatteryConfig battery3 = new BatteryConfig(2, R.drawable.ic_nothing_ear_r, R.string.right_earbud); + return new BatteryConfig[]{battery1, battery2, battery3}; + } + + @Override + public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) { + return new SoundcoreSportX20SettingsCustomizer(); + } + + @Override + public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) { + final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings(); + deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS); + deviceSpecificSettings.addSubScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS, R.xml.devicesettings_sony_headphones_ambient_sound_control_button_modes); + deviceSpecificSettings.addSubScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS, R.xml.devicesettings_soundcore_sport_x20_touch_options); + deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.AUDIO); + deviceSpecificSettings.addSubScreen(DeviceSpecificSettingsScreen.AUDIO, R.xml.devicesettings_soundcore_sport_x20_audio); + deviceSpecificSettings.addRootScreen(R.xml.devicesettings_soundcore_sport_x20); + deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS); + deviceSpecificSettings.addSubScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS, R.xml.devicesettings_headphones); + return deviceSpecificSettings; + } + + @Override + public DeviceCoordinator.DeviceKind getDeviceKind(@NonNull final GBDevice device) { + return DeviceKind.EARBUDS; + } + + @NonNull + @Override + public Class getDeviceSupportClass(final GBDevice device) { + return SoundcoreSportX20DeviceSupport.class; + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/sport_x20/SoundcoreSportX20SettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/sport_x20/SoundcoreSportX20SettingsCustomizer.java new file mode 100644 index 0000000000..7ec4125d3e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/sport_x20/SoundcoreSportX20SettingsCustomizer.java @@ -0,0 +1,99 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.soundcore.sport_x20; + +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND8_VALUE; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_CUSTOM; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_PRESET; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_RESET; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SOUNDCORE_ENABLE_PAIRING_MODE; + +import android.os.Parcel; + +import androidx.preference.ListPreference; +import androidx.preference.Preference; +import androidx.preference.SeekBarPreference; + +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.soundcore.sport_x20.SoundcoreSportX20Protocol; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; + +public class SoundcoreSportX20SettingsCustomizer implements DeviceSpecificSettingsCustomizer { + public static final Creator CREATOR = new Creator() { + @Override + public SoundcoreSportX20SettingsCustomizer createFromParcel(final Parcel in) { + return new SoundcoreSportX20SettingsCustomizer(); + } + + @Override + public SoundcoreSportX20SettingsCustomizer[] newArray(final int size) { + return new SoundcoreSportX20SettingsCustomizer[size]; + } + }; + + @Override + public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) { + if (!PREF_SOUNDCORE_EQUALIZER_PRESET.equals(preference.getKey())) { + return; + } + + final Preference equalizerCustom = handler.findPreference(PREF_SOUNDCORE_EQUALIZER_CUSTOM); + if (equalizerCustom == null) { + return; + } + + final String preset = ((ListPreference) preference).getValue(); + equalizerCustom.setEnabled("254".equals(preset)); + } + + @Override + public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) { + final Preference pairingMode = handler.findPreference(PREF_SOUNDCORE_ENABLE_PAIRING_MODE); + if (pairingMode != null) { + pairingMode.setOnPreferenceClickListener(pref -> { + handler.notifyPreferenceChanged(PREF_SOUNDCORE_ENABLE_PAIRING_MODE); + return true; + }); + } + + final Preference equalizerCustom = handler.findPreference(PREF_SOUNDCORE_EQUALIZER_CUSTOM); + if (equalizerCustom != null) { + final String preset = prefs.getString(PREF_SOUNDCORE_EQUALIZER_PRESET, "0"); + equalizerCustom.setEnabled("254".equals(preset)); + } + + final Preference equalizerReset = handler.findPreference(PREF_SOUNDCORE_EQUALIZER_RESET); + if (equalizerReset != null) { + equalizerReset.setOnPreferenceClickListener(pref -> resetEqualizer(handler)); + } + } + + private boolean resetEqualizer(final DeviceSpecificSettingsHandler handler) { + for (final String key : SoundcoreSportX20Protocol.EQUALIZER_PREFS_VALUE) { + final SeekBarPreference pref = handler.findPreference(key); + if (pref != null) { + pref.setValue(0); + } + } + + handler.notifyPreferenceChanged(PREF_SOUNDCORE_EQUALIZER_BAND8_VALUE); + return true; + } + + @Override + public Set getPreferenceKeysWithSummary() { + return Collections.emptySet(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + // No-op. + } +} 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 46b1327cd7..c2796a1f1c 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java @@ -433,6 +433,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.soundcore.liberty3_pro.Sound import nodomain.freeyourgadget.gadgetbridge.devices.soundcore.liberty4_nc.SoundcoreLiberty4NCCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.soundcore.motion300.SoundcoreMotion300Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.soundcore.q30.SoundcoreQ30Coordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.soundcore.sport_x20.SoundcoreSportX20Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.supercars.SuperCarsCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.thermalprinter.ThermalPrinterCoordinator; @@ -824,6 +825,7 @@ public enum DeviceType { SOUNDCORE_MOTION300(SoundcoreMotion300Coordinator.class), SOUNDCORE_Q30(SoundcoreQ30Coordinator.class), SOUNDCORE_AEROFIT2(SoundcoreAeroFit2Coordinator.class), + SOUNDCORE_SPORT_X20(SoundcoreSportX20Coordinator.class), MOONDROP_SPACE_TRAVEL(MoondropSpaceTravelCoordinator.class), MOONDROP_SPACE_TRAVEL_2(MoondropSpaceTravel2Coordinator.class), BOSE_QC35(QC35Coordinator.class), diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/sport_x20/SoundcoreSportX20DeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/sport_x20/SoundcoreSportX20DeviceSupport.java new file mode 100644 index 0000000000..8d8271f7b0 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/sport_x20/SoundcoreSportX20DeviceSupport.java @@ -0,0 +1,27 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.soundcore.sport_x20; + +import java.util.UUID; + +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.btbr.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.serial.AbstractHeadphoneSerialDeviceSupportV2; + +public class SoundcoreSportX20DeviceSupport extends AbstractHeadphoneSerialDeviceSupportV2 { + public static final UUID UUID_DEVICE_CTRL = UUID.fromString("0cf12d31-fac3-4553-bd80-d6832e7b3968"); + + public SoundcoreSportX20DeviceSupport() { + addSupportedService(UUID_DEVICE_CTRL); + } + + @Override + protected SoundcoreSportX20Protocol createDeviceProtocol() { + return new SoundcoreSportX20Protocol(getDevice()); + } + + @Override + protected TransactionBuilder initializeDevice(final TransactionBuilder builder) { + builder.write(mDeviceProtocol.encodeDeviceInfoRequest()); + builder.setDeviceState(GBDevice.State.INITIALIZED); + return builder; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/sport_x20/SoundcoreSportX20Protocol.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/sport_x20/SoundcoreSportX20Protocol.java new file mode 100644 index 0000000000..6d7dbc7a99 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/sport_x20/SoundcoreSportX20Protocol.java @@ -0,0 +1,379 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.soundcore.sport_x20; + +import android.content.SharedPreferences; + +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.Map; + +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.devices.soundcore.SoundcorePacket; +import nodomain.freeyourgadget.gadgetbridge.service.devices.soundcore.liberty.SoundcoreLibertyProtocol; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; + +public class SoundcoreSportX20Protocol extends SoundcoreLibertyProtocol { + private static final short CMD_SET_EQUALIZER = (short) 0x8703; + private static final int CUSTOM_PRESET_ID = 0xfe; + private static final int EQ_BANDS = 8; + + public static final String[] EQUALIZER_PREFS_VALUE = new String[]{ + DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND1_VALUE, + DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND2_VALUE, + DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND3_VALUE, + DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND4_VALUE, + DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND5_VALUE, + DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND6_VALUE, + DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND7_VALUE, + DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND8_VALUE, + }; + + private static final int[] EQ_TAIL_SCALE = new int[]{5, 3, 4, 4, 4, 4, 4, 6}; + private static final int EQ_TAIL_DIVISOR = 6; + + private static final String PRESET_CHANNEL_SEPARATOR = "00"; + private static final String PRESET_SUFFIX = "0000"; + private static final String PRESET_COMMON_REPEATED_BLOCK = "9fa18a998a8077643c3c"; + private static final String PRESET_COMMON_PREFIX = "00000200"; + private static final String PRESET_COMMON_MIDDLE = "69d64e6a00"; + private static final String PRESET_COMMON_SECTION = + PRESET_COMMON_PREFIX + + PRESET_COMMON_REPEATED_BLOCK + + PRESET_COMMON_REPEATED_BLOCK + + PRESET_COMMON_MIDDLE + + PRESET_COMMON_REPEATED_BLOCK + + PRESET_COMMON_REPEATED_BLOCK; + + private static final Map EQ_PRESET_PAYLOADS = buildPresetPayloads(); + + protected SoundcoreSportX20Protocol(final GBDevice device) { + super(device); + } + + @Override + public GBDeviceEvent[] decodeResponse(final byte[] responseData) { + final ByteBuffer buf = ByteBuffer.wrap(responseData); + final SoundcorePacket packet = SoundcorePacket.decode(buf); + + if (packet != null && packet.getCommand() == (short) 0x0106) { + decodeAncAudioMode(packet.getPayload()); + return new GBDeviceEvent[0]; + } + + if (packet != null && packet.getCommand() == CMD_SET_EQUALIZER) { + decodeEqualizer(packet.getPayload()); + return new GBDeviceEvent[0]; + } + + return super.decodeResponse(responseData); + } + + @Override + public byte[] encodeSendConfiguration(final String config) { + final Prefs prefs = getDevicePrefs(); + String prefString; + + switch (config) { + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_AMBIENT_SOUND_CONTROL: + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_WIND_NOISE_REDUCTION: + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_TRANSPARENCY_VOCAL_MODE: + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_ADAPTIVE_NOISE_CANCELLING: + case DeviceSettingsPreferenceConst.PREF_SONY_AMBIENT_SOUND_LEVEL: + return encodeAncAudioMode(); + + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_CONTROL_SINGLE_TAP_ACTION_LEFT: + prefString = prefs.getString(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_CONTROL_SINGLE_TAP_ACTION_LEFT, "PLAYPAUSE"); + return encodeControlFunctionMessage(TapAction.SINGLE_TAP, false, TapFunction.valueOf(prefString)); + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_CONTROL_SINGLE_TAP_ACTION_RIGHT: + prefString = prefs.getString(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_CONTROL_SINGLE_TAP_ACTION_RIGHT, "PLAYPAUSE"); + return encodeControlFunctionMessage(TapAction.SINGLE_TAP, true, TapFunction.valueOf(prefString)); + + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_CONTROL_DOUBLE_TAP_ACTION_LEFT: + prefString = prefs.getString(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_CONTROL_DOUBLE_TAP_ACTION_LEFT, "MEDIA_PREV"); + return encodeControlFunctionMessage(TapAction.DOUBLE_TAP, false, TapFunction.valueOf(prefString)); + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_CONTROL_DOUBLE_TAP_ACTION_RIGHT: + prefString = prefs.getString(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_CONTROL_DOUBLE_TAP_ACTION_RIGHT, "MEDIA_NEXT"); + return encodeControlFunctionMessage(TapAction.DOUBLE_TAP, true, TapFunction.valueOf(prefString)); + + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_CONTROL_LONG_PRESS_ACTION_LEFT: + prefString = prefs.getString(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_CONTROL_LONG_PRESS_ACTION_LEFT, "AMBIENT_SOUND_CONTROL"); + return encodeControlFunctionMessage(TapAction.LONG_PRESS, false, TapFunction.valueOf(prefString)); + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_CONTROL_LONG_PRESS_ACTION_RIGHT: + prefString = prefs.getString(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_CONTROL_LONG_PRESS_ACTION_RIGHT, "AMBIENT_SOUND_CONTROL"); + return encodeControlFunctionMessage(TapAction.LONG_PRESS, true, TapFunction.valueOf(prefString)); + + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_AUTO_POWER_OFF: + final int duration = Integer.parseInt(prefs.getString(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_AUTO_POWER_OFF, "3")); + return encodeAutoPowerOff(duration); + + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_TOUCH_TONE: + final boolean pressAlert = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_TOUCH_TONE, false); + return new SoundcorePacket((short) 0x8301, new byte[]{encodeBoolean(pressAlert)}).encode(); + + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_3D_SURROUND: + final boolean surround3d = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_3D_SURROUND, false); + return new SoundcorePacket((short) 0x8602, new byte[]{encodeBoolean(surround3d)}).encode(); + + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_PRESET: + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND1_VALUE: + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND2_VALUE: + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND3_VALUE: + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND4_VALUE: + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND5_VALUE: + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND6_VALUE: + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND7_VALUE: + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_BAND8_VALUE: + return encodeEqualizer(); + + case DeviceSettingsPreferenceConst.PREF_SOUNDCORE_ENABLE_PAIRING_MODE: + return new SoundcorePacket((short) 0x850b, new byte[]{0x00, (byte) 0x90}).encode(); + + default: + return super.encodeSendConfiguration(config); + } + } + + @Override + public byte[] encodeFindDevice(final boolean start) { + final boolean findLeft = start; + final boolean findRight = start; + final byte[] payload = new byte[]{encodeBoolean(findLeft), encodeBoolean(findRight), 0x00}; + return new SoundcorePacket((short) 0x8910, payload).encode(); + } + + private byte[] encodeControlFunctionMessage(final TapAction action, final boolean right, final TapFunction function) { + final byte functionByte; + switch (action) { + case SINGLE_TAP: + functionByte = (byte) (16 * 6 + function.getCode()); + break; + case DOUBLE_TAP: + functionByte = (byte) (16 * 3 + function.getCode()); + break; + case LONG_PRESS: + functionByte = (byte) (16 * 4 + function.getCode()); + break; + default: + return null; + } + + final byte[] payload = new byte[]{encodeBoolean(right), action.getCode(), functionByte}; + return new SoundcorePacket((short) 0x8104, payload).encode(); + } + + /** + * 0: No Auto Power off + * 1: Auto Power off 10 min + * 2: Auto Power off 20 min + * 3: Auto Power off 30 min + * 4: Auto Power off 60 min + */ + private byte[] encodeAutoPowerOff(final int duration) { + final byte[] payload; + + if (duration > 0) { + payload = new byte[]{(byte) 0x01, (byte) (duration - 1)}; + } else { + payload = new byte[]{(byte) 0x00, (byte) 0x03}; + } + + return new SoundcorePacket((short) 0x8601, payload).encode(); + } + + private void decodeAncAudioMode(final byte[] payload) { + if (payload.length < 5) { + return; + } + + final SharedPreferences prefs = getDevicePrefs().getPreferences(); + final SharedPreferences.Editor editor = prefs.edit(); + + String ambientSoundMode = "off"; + if (payload[0] == 0x00) { + ambientSoundMode = "noise_cancelling"; + } else if (payload[0] == 0x01) { + ambientSoundMode = "ambient_sound"; + } else if (payload[0] == 0x02) { + ambientSoundMode = "off"; + } + + int ancStrength = ((payload[1] & 0x30) >> 4) - 1; + + final boolean vocalMode = (payload[2] == 0x01); + final boolean adaptiveAnc = (payload[3] == 0x01); + final boolean windNoiseReduction = (payload[4] == 0x01); + + editor.putString(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_AMBIENT_SOUND_CONTROL, ambientSoundMode); + editor.putInt(DeviceSettingsPreferenceConst.PREF_SONY_AMBIENT_SOUND_LEVEL, ancStrength); + editor.putBoolean(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_TRANSPARENCY_VOCAL_MODE, vocalMode); + editor.putBoolean(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_ADAPTIVE_NOISE_CANCELLING, adaptiveAnc); + editor.putBoolean(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_WIND_NOISE_REDUCTION, windNoiseReduction); + editor.apply(); + } + + private byte[] encodeAncAudioMode() { + final Prefs prefs = getDevicePrefs(); + + final String ambientMode = prefs.getString(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_AMBIENT_SOUND_CONTROL, "off"); + final int ancStrengthValue = prefs.getInt(DeviceSettingsPreferenceConst.PREF_SONY_AMBIENT_SOUND_LEVEL, 0); + final boolean vocalMode = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_TRANSPARENCY_VOCAL_MODE, false); + final boolean adaptive = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_ADAPTIVE_NOISE_CANCELLING, true); + final boolean windReduction = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_WIND_NOISE_REDUCTION, false); + + if (ancStrengthValue < 0 || ancStrengthValue > 2) { + return null; + } + final byte ancStrengthByte = (byte) ((ancStrengthValue + 1) << 4); + + final byte ambientModeByte; + switch (ambientMode) { + case "noise_cancelling": + ambientModeByte = 0x00; + break; + case "ambient_sound": + ambientModeByte = 0x01; + break; + case "off": + ambientModeByte = 0x02; + break; + default: + return null; + } + + final byte adaptiveByte = encodeBoolean(adaptive); + final byte vocalModeByte = encodeBoolean(vocalMode); + final byte windReductionByte = encodeBoolean(windReduction); + + final byte[] payload = new byte[]{ambientModeByte, ancStrengthByte, vocalModeByte, adaptiveByte, windReductionByte}; + return new SoundcorePacket((short) 0x8106, payload).encode(); + } + + private byte[] encodeEqualizer() { + final Prefs prefs = getDevicePrefs(); + final int preset = Integer.parseInt(prefs.getString(DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_PRESET, "0")); + + if (preset != CUSTOM_PRESET_ID) { + final byte[] presetPayload = EQ_PRESET_PAYLOADS.get(preset); + if (presetPayload != null) { + return new SoundcorePacket(CMD_SET_EQUALIZER, presetPayload).encode(); + } + } + + final int[] rawBands = new int[EQ_BANDS]; + final int[] tailBands = new int[EQ_BANDS]; + + for (int i = 0; i < EQ_BANDS; i++) { + final int value = clamp(prefs.getInt(EQUALIZER_PREFS_VALUE[i], 0), -6, 6); + + rawBands[i] = 120 + (value * 10); + tailBands[i] = 120 + Math.round((float) (value * EQ_TAIL_SCALE[i]) / EQ_TAIL_DIVISOR); + } + + final byte[] payload = buildPresetPayload( + CUSTOM_PRESET_ID, + bandsToHex(rawBands), + bandsToHex(tailBands) + ); + + return new SoundcorePacket(CMD_SET_EQUALIZER, payload).encode(); + } + + private void decodeEqualizer(final byte[] payload) { + if (payload.length < 90) { + return; + } + + final int preset = Byte.toUnsignedInt(payload[0]); + final SharedPreferences prefs = getDevicePrefs().getPreferences(); + final SharedPreferences.Editor editor = prefs.edit(); + + editor.putString( + DeviceSettingsPreferenceConst.PREF_SOUNDCORE_EQUALIZER_PRESET, + String.valueOf(preset) + ); + + for (int i = 0; i < EQ_BANDS; i++) { + final int rawBand = Byte.toUnsignedInt(payload[4 + i]); + final int value = clamp(Math.round((rawBand - 120) / 10f), -6, 6); + editor.putInt(EQUALIZER_PREFS_VALUE[i], value); + } + + editor.apply(); + } + + private static Map buildPresetPayloads() { + final Map presetPayloads = new HashMap<>(); + + presetPayloads.put(0x00, buildPresetPayload(0x00, "787878787878787878", "787878787878787878")); + presetPayloads.put(0x01, buildPresetPayload(0x01, "a0828c8ca0a0a08c78", "7d767b787c7a7c7978")); + presetPayloads.put(0x02, buildPresetPayload(0x02, "a09682787878787878", "7b7a78787878787878")); + presetPayloads.put(0x03, buildPresetPayload(0x03, "505a6e787878787878", "757678787878787878")); + presetPayloads.put(0x04, buildPresetPayload(0x04, "96966464788c96a078", "7a7c7477787a797d78")); + presetPayloads.put(0x05, buildPresetPayload(0x05, "5a8ca0a0968c786478", "747b7a7b797a787578")); + presetPayloads.put(0x06, buildPresetPayload(0x06, "8c5a6e828c8c825a78", "7c7378787a797b7378")); + presetPayloads.put(0x07, buildPresetPayload(0x07, "8c8296968c64504678", "7a777b797b76767378")); + presetPayloads.put(0x08, buildPresetPayload(0x08, "968c648c828c969678", "7a7b737d777a7a7b78")); + presetPayloads.put(0x09, buildPresetPayload(0x09, "64646e787878646478", "767777787879767678")); + presetPayloads.put(0x0a, buildPresetPayload(0x0a, "8c966e6e8c6e8c9678", "797c76767d747b7b78")); + presetPayloads.put(0x0b, buildPresetPayload(0x0b, "8c8c6464788c96a078", "797b7577787a797d78")); + presetPayloads.put(0x0c, buildPresetPayload(0x0c, "78786464647896aa78", "7879767776787a7e78")); + presetPayloads.put(0x0d, buildPresetPayload(0x0d, "6e8ca09678648c8278", "767a7b7a78747c7878")); + presetPayloads.put(0x0e, buildPresetPayload(0x0e, "7896968ca0aa96a078", "777b7a787b7c787d78")); + presetPayloads.put(0x0f, buildPresetPayload(0x0f, "6e829696826e645a78", "77797a7a7977777578")); + presetPayloads.put(0x10, buildPresetPayload(0x10, "b48c64648c9696a078", "7e7976757b7a797d78")); + presetPayloads.put(0x11, buildPresetPayload(0x11, "968c6e6e8296a0aa78", "7a7a7677797a7a7e78")); + presetPayloads.put(0x12, buildPresetPayload(0x12, "a0968278645a505078", "7b7a78797676757478")); + presetPayloads.put(0x13, buildPresetPayload(0x13, "5a64828c8c82785a78", "76767a797a78797478")); + presetPayloads.put(0x14, buildPresetPayload(0x14, "6464646e828c8ca078", "76777777797a787d78")); + presetPayloads.put(0x15, buildPresetPayload(0x15, "787878645a50503c78", "787879767775777178")); + + return presetPayloads; + } + + private static byte[] buildPresetPayload(final int presetId, final String bands, final String tails) { + return decodeHex( + toHexByte(presetId) + + "000000" + + bands + + PRESET_CHANNEL_SEPARATOR + + bands + + PRESET_COMMON_SECTION + + tails + + PRESET_CHANNEL_SEPARATOR + + tails + + PRESET_SUFFIX + ); + } + + private static String bandsToHex(final int[] bands) { + final StringBuilder sb = new StringBuilder(bands.length * 2); + + for (final int band : bands) { + sb.append(toHexByte(band)); + } + + return sb.toString(); + } + + private static String toHexByte(final int value) { + final String hex = Integer.toHexString(value & 0xff); + return hex.length() < 2 ? "0" + hex : hex; + } + + private static byte[] decodeHex(final String hex) { + final int len = hex.length(); + final byte[] data = new byte[len / 2]; + + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4) + + Character.digit(hex.charAt(i + 1), 16)); + } + + return data; + } + + private static int clamp(final int value, final int min, final int max) { + return Math.max(min, Math.min(max, value)); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/sport_x20/TapAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/sport_x20/TapAction.java new file mode 100644 index 0000000000..0655a42303 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/sport_x20/TapAction.java @@ -0,0 +1,18 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.soundcore.sport_x20; + +enum TapAction { + SINGLE_TAP((byte) 0x02), + DOUBLE_TAP((byte) 0x00), + LONG_PRESS((byte) 0x01) + ; + + private final byte code; + + TapAction(final byte code) { + this.code = code; + } + + public byte getCode() { + return code; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/sport_x20/TapFunction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/sport_x20/TapFunction.java new file mode 100644 index 0000000000..0a639c629a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/sport_x20/TapFunction.java @@ -0,0 +1,23 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.soundcore.sport_x20; + +enum TapFunction { + VOLUME_DOWN(1), + VOLUME_UP(0), + MEDIA_NEXT(3), + MEDIA_PREV(2), + PLAYPAUSE(6), + VOICE_ASSISTANT(5), + AMBIENT_SOUND_CONTROL(4), + NONE(15) + ; + + private final int code; + + TapFunction(final int code) { + this.code = code; + } + + public int getCode() { + return code; + } +} diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index af00c38c90..565530a444 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -4567,6 +4567,120 @@ AMBIENT_SOUND_CONTROL + + @string/pref_media_volumedown + @string/pref_media_volumeup + @string/pref_media_next + @string/pref_media_previous + @string/pref_media_playpause + @string/none + + + + VOLUME_DOWN + VOLUME_UP + MEDIA_NEXT + MEDIA_PREV + PLAYPAUSE + NONE + + + + @string/pref_media_volumedown + @string/pref_media_volumeup + @string/pref_media_next + @string/pref_media_previous + @string/pref_media_playpause + @string/pref_title_touch_voice_assistant + @string/sony_button_mode_ambient_sound_control + @string/none + + + + VOLUME_DOWN + VOLUME_UP + MEDIA_NEXT + MEDIA_PREV + PLAYPAUSE + VOICE_ASSISTANT + AMBIENT_SOUND_CONTROL + NONE + + + + @string/pref_media_volumedown + @string/pref_media_volumeup + @string/pref_media_next + @string/pref_media_previous + @string/pref_media_playpause + @string/pref_title_touch_voice_assistant + @string/sony_button_mode_ambient_sound_control + @string/none + + + + VOLUME_DOWN + VOLUME_UP + MEDIA_NEXT + MEDIA_PREV + PLAYPAUSE + VOICE_ASSISTANT + AMBIENT_SOUND_CONTROL + NONE + + + + @string/soundcore_equalizer_preset_signature + @string/soundcore_equalizer_preset_acoustic + @string/sony_equalizer_preset_bass_boost + @string/soundcore_equalizer_preset_bass_reducer + @string/soundcore_equalizer_preset_classical + @string/soundcore_equalizer_preset_podcast + @string/soundcore_equalizer_preset_dance + @string/soundcore_equalizer_preset_deep + @string/soundcore_equalizer_preset_electronic + @string/soundcore_equalizer_preset_flat + @string/soundcore_equalizer_preset_hip_hop + @string/soundcore_equalizer_preset_jazz + @string/soundcore_equalizer_preset_latin + @string/soundcore_equalizer_preset_lounge + @string/soundcore_equalizer_preset_piano + @string/soundcore_equalizer_preset_pop + @string/soundcore_equalizer_preset_rnb + @string/soundcore_equalizer_preset_rock + @string/soundcore_equalizer_preset_small_speakers + @string/soundcore_equalizer_preset_spoken_word + @string/sony_equalizer_preset_treble_boost + @string/soundcore_equalizer_preset_treble_reducer + @string/custom + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + 254 + + @string/off @string/soundcore_button_brightness_low diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ce984cce9..af1b4d35ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2146,6 +2146,7 @@ Soundcore Motion 300 Soundcore Q30 Soundcore AeroFit 2 + Soundcore Sport X20 Pixel Buds A-Series Moondrop Space Travel Moondrop Space Travel 2 @@ -3517,6 +3518,9 @@ Enabling LDAC will decrease the battery life and might lead to connection instability Auto Power Off When disconnectiong from the phone, the device will power off after the specified timeout + 3D Surround Sound + Enable 3D surrounding sound + Enable discovery/pairing mode to connect a new device Low Battery Tone Hear the prompt tone when the battery level is below 20% and 10% Gaming Mode @@ -3528,6 +3532,25 @@ Xtra Bass Voice Balanced + Acoustic + Bass Reducer + Classical + Podcast + Dance + Deep + Electronic + Flat + Hip-Hop + Jazz + Latin + Lounge + Piano + Pop + R&B + Rock + Small Speakers + Spoken Word + Treble Reducer Customize… Configure parametric equalizer settings Device Direction @@ -3547,6 +3570,14 @@ Band 9 Frequency Value + 100 Hz + 200 Hz + 400 Hz + 800 Hz + 1.6 kHz + 3.2 kHz + 6.4 kHz + 12.8 kHz Weight Unit Set unit of weight for displayed measurements Metric (kg) diff --git a/app/src/main/res/xml/devicesettings_soundcore_sport_x20.xml b/app/src/main/res/xml/devicesettings_soundcore_sport_x20.xml new file mode 100644 index 0000000000..4534118938 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_soundcore_sport_x20.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/devicesettings_soundcore_sport_x20_audio.xml b/app/src/main/res/xml/devicesettings_soundcore_sport_x20_audio.xml new file mode 100644 index 0000000000..67301885a5 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_soundcore_sport_x20_audio.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/devicesettings_soundcore_sport_x20_touch_options.xml b/app/src/main/res/xml/devicesettings_soundcore_sport_x20_touch_options.xml new file mode 100644 index 0000000000..6a87fde1e6 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_soundcore_sport_x20_touch_options.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file