diff --git a/.idea/dictionaries/t.xml b/.idea/dictionaries/t.xml index 62cfa2e832..4366d93e72 100644 --- a/.idea/dictionaries/t.xml +++ b/.idea/dictionaries/t.xml @@ -147,6 +147,7 @@ sevostyanova shahrabani shimokawa + shokz skype slezak sophanimus 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 3a53b96481..b5888778c1 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 @@ -516,6 +516,13 @@ public class DeviceSettingsPreferenceConst { public static final String PREF_SONY_ADAPTIVE_VOLUME_CONTROL = "pref_adaptive_volume_control"; public static final String PREF_SONY_WIDE_AREA_TAP = "pref_wide_area_tap"; + public static final String PREF_MEDIA_SOURCE = "pref_media_source"; + public static final String PREF_MEDIA_PLAYBACK_MODE = "pref_media_playback_mode"; + public static final String PREF_SHOKZ_EQUALIZER_BLUETOOTH = "pref_shokz_equalizer_bluetooth"; + public static final String PREF_SHOKZ_EQUALIZER_MP3 = "pref_shokz_equalizer_mp3"; + public static final String PREF_SHOKZ_CONTROLS_LONG_PRESS_MULTI_FUNCTION = "shokz_controls_long_press_multi_function"; + public static final String PREF_SHOKZ_CONTROLS_SIMULTANEOUS_VOLUME_UP_DOWN = "shokz_controls_simultaneous_volume_up_down"; + public static final String PREF_OVERRIDE_FEATURES_ENABLED = "override_features_enabled"; public static final String PREF_OVERRIDE_FEATURES_LIST = "override_features_list"; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettings.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettings.java index 5cd514f96a..f42f19d7d2 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettings.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettings.java @@ -24,6 +24,7 @@ import androidx.annotation.Nullable; import androidx.annotation.XmlRes; import java.util.ArrayList; +import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -47,6 +48,9 @@ public class DeviceSpecificSettings implements Parcelable { private final List rootScreens = new ArrayList<>(); private final Map> subScreens = new LinkedHashMap<>(); + /// Settings that should only be available while connected + private final List connectedPreferences = new ArrayList<>(); + public DeviceSpecificSettings() { } @@ -115,6 +119,7 @@ public class DeviceSpecificSettings implements Parcelable { Objects.requireNonNull(subScreens.get(e.getKey())).add(screen); } } + connectedPreferences.addAll(deviceSpecificSettings.connectedPreferences); } public List getRootScreens() { @@ -126,6 +131,14 @@ public class DeviceSpecificSettings implements Parcelable { return subScreens.get(key); } + public void addConnectedPreferences(final String... preferences) { + connectedPreferences.addAll(Arrays.asList(preferences)); + } + + public List getConnectedPreferences() { + return connectedPreferences; + } + public List getAllNonRootScreens() { final List allScreens = new ArrayList<>(); for (final List screens : subScreens.values()) { @@ -143,7 +156,7 @@ public class DeviceSpecificSettings implements Parcelable { return allScreens; } - public static final Creator CREATOR = new Creator() { + public static final Creator CREATOR = new Creator<>() { @Override public DeviceSpecificSettings createFromParcel(final Parcel in) { final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings(); @@ -161,6 +174,7 @@ public class DeviceSpecificSettings implements Parcelable { } deviceSpecificSettings.addSubScreen(key, screens); } + in.readStringList(deviceSpecificSettings.connectedPreferences); return deviceSpecificSettings; } @@ -189,5 +203,6 @@ public class DeviceSpecificSettings implements Parcelable { dest.writeInt(s); } } + dest.writeStringList(connectedPreferences); } } 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 eb6e0005f1..19609ca622 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 @@ -103,6 +103,7 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i static final String FRAGMENT_TAG = "DEVICE_SPECIFIC_SETTINGS_FRAGMENT"; + private DeviceSpecificSettings deviceSpecificSettings; private DeviceSpecificSettingsCustomizer deviceSpecificSettingsCustomizer; private GBDevice device; @@ -142,6 +143,7 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i LOG.debug("{} changed, notifying customizer", changedDevice); deviceSpecificSettingsCustomizer.onDeviceChanged(DeviceSpecificSettingsFragment.this); } + reloadEnabledPreferences(); } } } @@ -174,7 +176,7 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i return; } String settingsFileSuffix = arguments.getString("settingsFileSuffix", null); - DeviceSpecificSettings deviceSpecificSettings = arguments.getParcelable("deviceSpecificSettings"); + this.deviceSpecificSettings = arguments.getParcelable("deviceSpecificSettings"); this.deviceSpecificSettingsCustomizer = arguments.getParcelable("deviceSpecificSettingsCustomizer"); this.device = arguments.getParcelable("device"); @@ -241,6 +243,19 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i } setChangeListener(rootKey); + + reloadEnabledPreferences(); + } + + private void reloadEnabledPreferences() { + if (deviceSpecificSettings != null) { + for (String connectedPreference : deviceSpecificSettings.getConnectedPreferences()) { + final Preference pref = findPreference(connectedPreference); + if (pref != null) { + pref.setEnabled(device.isInitialized()); + } + } + } } private void addDynamicSettings(final String rootKey) { @@ -811,6 +826,14 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i addPreferenceHandlerFor(PREF_SONY_ADAPTIVE_VOLUME_CONTROL); addPreferenceHandlerFor(PREF_SONY_WIDE_AREA_TAP); + addPreferenceHandlerFor(PREF_MEDIA_SOURCE); + addPreferenceHandlerFor(PREF_MEDIA_PLAYBACK_MODE); + addPreferenceHandlerFor(PREF_SHOKZ_EQUALIZER_BLUETOOTH); + addPreferenceHandlerFor(PREF_SHOKZ_EQUALIZER_MP3); + + addPreferenceHandlerFor(PREF_SHOKZ_CONTROLS_LONG_PRESS_MULTI_FUNCTION); + addPreferenceHandlerFor(PREF_SHOKZ_CONTROLS_SIMULTANEOUS_VOLUME_UP_DOWN); + addPreferenceHandlerFor(PREF_SOUNDCORE_AMBIENT_SOUND_CONTROL); addPreferenceHandlerFor(PREF_SOUNDCORE_WIND_NOISE_REDUCTION); addPreferenceHandlerFor(PREF_SOUNDCORE_TRANSPARENCY_VOCAL_MODE); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsScreen.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsScreen.java index 18e2655374..09ad624c83 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsScreen.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsScreen.java @@ -16,6 +16,7 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.activities.devicesettings; +import androidx.annotation.NonNull; import androidx.annotation.XmlRes; import nodomain.freeyourgadget.gadgetbridge.R; @@ -50,6 +51,7 @@ public enum DeviceSpecificSettingsScreen { this.xml = xml; } + @NonNull public String getKey() { return key; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEvent.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEvent.java index 5254b6739a..088136135d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEvent.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEvent.java @@ -50,9 +50,6 @@ public abstract class GBDeviceEvent { /** * Helper method to run specific actions configured in the device preferences, upon wear state * or awake/asleep events. - * - * @param actions - * @param message */ protected void handleDeviceAction(final Context context, final GBDevice device, Set actions, String message, String broadcastPackage) { if (actions.isEmpty()) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdatePreferences.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdatePreferences.java index 337b5de3d0..9f5dbb1311 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdatePreferences.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdatePreferences.java @@ -19,6 +19,8 @@ package nodomain.freeyourgadget.gadgetbridge.deviceevents; import android.content.Context; import android.content.SharedPreferences; +import androidx.annotation.NonNull; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,6 +36,18 @@ public class GBDeviceEventUpdatePreferences extends GBDeviceEvent { public final Map preferences; + @NonNull + @Override + public String toString() { + final StringBuilder sb = new StringBuilder(super.toString()); + sb.append("preferences: "); + for (Map.Entry e : preferences.entrySet()) { + sb.append(e.getKey()).append("=").append(e.getValue()).append(", "); + } + sb.setLength(sb.length() - 2); + return sb.toString(); + } + public GBDeviceEventUpdatePreferences() { this.preferences = new HashMap<>(); } @@ -73,10 +87,11 @@ public class GBDeviceEventUpdatePreferences extends GBDeviceEvent { private void update(final SharedPreferences prefs) { final SharedPreferences.Editor editor = prefs.edit(); - for (String key : preferences.keySet()) { - final Object value = preferences.get(key); + for (Map.Entry e : preferences.entrySet()) { + final String key = e.getKey(); + final Object value = e.getValue(); - LOG.debug("Updating {} = {}", key, value); + LOG.trace("Updating {} = {}", key, value); if (value == null) { editor.remove(key); @@ -93,6 +108,7 @@ public class GBDeviceEventUpdatePreferences extends GBDeviceEvent { } else if (value instanceof Long) { editor.putLong(key, (Long) value); } else if (value instanceof Set) { + //noinspection unchecked,rawtypes editor.putStringSet(key, (Set) value); } else { LOG.warn("Unknown preference value type {} for {}", value.getClass(), key); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/shokz/ShokzCoordinator.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/shokz/ShokzCoordinator.kt new file mode 100644 index 0000000000..6b56f44d97 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/shokz/ShokzCoordinator.kt @@ -0,0 +1,70 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.shokz + +import nodomain.freeyourgadget.gadgetbridge.R +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst +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.impl.GBDevice +import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport +import nodomain.freeyourgadget.gadgetbridge.service.devices.shokz.ShokzSupport + +abstract class ShokzCoordinator : AbstractBLClassicDeviceCoordinator() { + override fun getManufacturer(): String? { + return "Shokz" + } + + override fun getDeviceSupportClass(device: GBDevice): Class { + return ShokzSupport::class.java + } + + override fun suggestUnbindBeforePair(): Boolean { + return false + } + + override fun getDefaultIconResource(): Int { + // TODO dedicated icon + return R.drawable.ic_device_headphones + } + + override fun getSupportedLanguageSettings(device: GBDevice): Array? { + return arrayOf( + "en", + "zh", + "ja", + "ko", + ) + } + + override fun getDeviceSpecificSettings(device: GBDevice): DeviceSpecificSettings { + val settings = DeviceSpecificSettings() + + settings.addRootScreen(R.xml.devicesettings_media_source) + settings.addRootScreen(R.xml.devicesettings_shokz_equalizer) + settings.addRootScreen(R.xml.devicesettings_playback_mode) + + settings.addRootScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS) + settings.addSubScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS, R.xml.devicesettings_shokz_controls) + + settings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS) + settings.addSubScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS, R.xml.devicesettings_headphones) + + settings.addConnectedPreferences( + DeviceSettingsPreferenceConst.PREF_LANGUAGE, + DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE, + DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_BLUETOOTH, + DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_MP3, + DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE, + DeviceSpecificSettingsScreen.TOUCH_OPTIONS.key, + DeviceSettingsPreferenceConst.PREF_SHOKZ_CONTROLS_LONG_PRESS_MULTI_FUNCTION, + DeviceSettingsPreferenceConst.PREF_SHOKZ_CONTROLS_SIMULTANEOUS_VOLUME_UP_DOWN, + ) + + return settings + } + + override fun getDeviceSpecificSettingsCustomizer(device: GBDevice): DeviceSpecificSettingsCustomizer? { + return ShokzSettingsCustomizer() + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/shokz/ShokzOpenSwimProCoordinator.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/shokz/ShokzOpenSwimProCoordinator.kt new file mode 100644 index 0000000000..2097b56df7 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/shokz/ShokzOpenSwimProCoordinator.kt @@ -0,0 +1,14 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.shokz + +import nodomain.freeyourgadget.gadgetbridge.R +import java.util.regex.Pattern + +class ShokzOpenSwimProCoordinator: ShokzCoordinator() { + override fun getSupportedDeviceName(): Pattern? { + return Pattern.compile("^OpenSwim Pro by Shokz$") + } + + override fun getDeviceNameResource(): Int { + return R.string.devicetype_shokz_openswim_pro + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/shokz/ShokzSettingsCustomizer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/shokz/ShokzSettingsCustomizer.kt new file mode 100644 index 0000000000..03ebe7dbed --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/shokz/ShokzSettingsCustomizer.kt @@ -0,0 +1,75 @@ +/* Copyright (C) 2025 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.shokz + +import androidx.preference.Preference +import kotlinx.parcelize.Parcelize +import nodomain.freeyourgadget.gadgetbridge.GBApplication +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler +import nodomain.freeyourgadget.gadgetbridge.service.devices.shokz.ShokzMediaSource +import nodomain.freeyourgadget.gadgetbridge.util.Prefs +import org.slf4j.LoggerFactory + +@Parcelize +class ShokzSettingsCustomizer : DeviceSpecificSettingsCustomizer { + override fun onPreferenceChange( + preference: Preference, + handler: DeviceSpecificSettingsHandler + ) { + } + + override fun onDeviceChanged(handler: DeviceSpecificSettingsHandler) { + hideEqualizers(handler, GBApplication.getDevicePrefs(handler.device)) + } + + override fun customizeSettings( + handler: DeviceSpecificSettingsHandler, + genericDevicePrefs: Prefs, + rootKey: String? + ) { + hideEqualizers(handler, genericDevicePrefs) + } + + private fun hideEqualizers( + handler: DeviceSpecificSettingsHandler, + devicePrefs: Prefs + ) { + val mediaSourceValue: String = + devicePrefs.getString(DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE, "") + val mediaSource = ShokzMediaSource.fromPreference(mediaSourceValue) ?: run { + LOG.warn("Unknown media source {}", mediaSourceValue) + null + } + + handler.findPreference(DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_BLUETOOTH)?.isVisible = + mediaSource == ShokzMediaSource.BLUETOOTH + handler.findPreference(DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_MP3)?.isVisible = + mediaSource == ShokzMediaSource.MP3 + handler.findPreference(DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE)?.isVisible = + mediaSource == ShokzMediaSource.MP3 + } + + override fun getPreferenceKeysWithSummary(): Set { + return setOf() + } + + companion object { + private val LOG = LoggerFactory.getLogger(ShokzSettingsCustomizer::class.java) + } +} 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 df8851dc61..70b0398bc3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java @@ -315,6 +315,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.redmibuds.RedmiBuds6ProCoord import nodomain.freeyourgadget.gadgetbridge.devices.roidmi.Roidmi1Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.roidmi.Roidmi3Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.scannable.ScannableDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.shokz.ShokzOpenSwimProCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.smaq2oss.SMAQ2OSSCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.soflow.SoFlowCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyLinkBudsCoordinator; @@ -662,6 +663,7 @@ public enum DeviceType { SONY_WF_C700N(SonyWFC700NCoordinator.class), SONY_WF_C710N(SonyWFC710NCoordinator.class), PIXEL_BUDS_A(PixelBudsACoordinator.class), + SHOKZ_OPENSWIM_PRO(ShokzOpenSwimProCoordinator.class), SOUNDCORE_LIBERTY3_PRO(SoundcoreLiberty3ProCoordinator.class), SOUNDCORE_LIBERTY4_NC(SoundcoreLiberty4NCCoordinator.class), SOUNDCORE_MOTION300(SoundcoreMotion300Coordinator.class), diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractHeadphoneBTBRDeviceSupport.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractHeadphoneBTBRDeviceSupport.kt new file mode 100644 index 0000000000..7ae8e060a0 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractHeadphoneBTBRDeviceSupport.kt @@ -0,0 +1,41 @@ +package nodomain.freeyourgadget.gadgetbridge.service + +import android.bluetooth.BluetoothAdapter +import android.content.Context +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice +import nodomain.freeyourgadget.gadgetbridge.model.CallSpec +import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec +import nodomain.freeyourgadget.gadgetbridge.service.btbr.AbstractBTBRDeviceSupport +import org.slf4j.Logger + +abstract class AbstractHeadphoneBTBRDeviceSupport(logger: Logger, bufferSize: Int = 1024) : + AbstractBTBRDeviceSupport(logger, bufferSize), HeadphoneHelper.Callback { + + private lateinit var headphoneHelper: HeadphoneHelper + + override fun setContext(gbDevice: GBDevice, btAdapter: BluetoothAdapter, context: Context) { + super.setContext(gbDevice, btAdapter, context) + headphoneHelper = HeadphoneHelper(getContext(), gbDevice, this) + } + + override fun dispose() { + synchronized(ConnectionMonitor) { + headphoneHelper.dispose() + super.dispose() + } + } + + override fun onSetCallState(callSpec: CallSpec) { + headphoneHelper.onSetCallState(callSpec) + } + + override fun onNotification(notificationSpec: NotificationSpec) { + headphoneHelper.onNotification(notificationSpec) + } + + override fun onSendConfiguration(config: String) { + if (!headphoneHelper.onSendConfiguration(config)) { + super.onSendConfiguration(config) + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractBTBRDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractBTBRDeviceSupport.java index fcffc4d2fd..1b6d04f363 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractBTBRDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractBTBRDeviceSupport.java @@ -16,6 +16,10 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.btbr; +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothDevice; +import android.os.ParcelUuid; + import org.slf4j.Logger; import java.io.IOException; @@ -24,6 +28,7 @@ import java.util.UUID; import nodomain.freeyourgadget.gadgetbridge.Logging; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.service.AbstractDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btle.BleNamesResolver; /** * Abstract base class for devices connected through a serial protocol, like RFCOMM BT or TCP socket. @@ -42,11 +47,15 @@ public abstract class AbstractBTBRDeviceSupport extends AbstractDeviceSupport im private BtBRQueue mQueue; private UUID mSupportedService = null; - private int mBufferSize = 1024; + private final int mBufferSize; private final Logger logger; - public AbstractBTBRDeviceSupport(Logger logger) { + /** + * @param bufferSize should be larger than the maximum expected message side, or messages might be lost. + */ + public AbstractBTBRDeviceSupport(Logger logger, final int bufferSize) { this.logger = logger; + this.mBufferSize = bufferSize; if (logger == null) { throw new IllegalArgumentException("logger must not be null"); } @@ -57,6 +66,21 @@ public abstract class AbstractBTBRDeviceSupport extends AbstractDeviceSupport im synchronized (ConnectionMonitor) { final UUID supportedService = getSupportedService(); if (supportedService == null) { + // Before throwing the exception, list the available UUIDs + final BluetoothDevice btDevice = getBluetoothAdapter().getRemoteDevice(gbDevice.getAddress()); + @SuppressLint("MissingPermission") final ParcelUuid[] uuids = btDevice.getUuids(); + if (uuids == null || uuids.length == 0) { + logger.warn("Device provided no UUIDs to connect to: {}", gbDevice); + } else { + for (ParcelUuid uuid : uuids) { + logger.debug( + "discovered service: {}: {}", + BleNamesResolver.resolveServiceName(uuid.toString()), + uuid + ); + } + } + throw new NullPointerException("No supported service UUID specified"); } @@ -136,10 +160,6 @@ public abstract class AbstractBTBRDeviceSupport extends AbstractDeviceSupport im return mSupportedService; } - protected void setBufferSize(int bufferSize) { - mBufferSize = bufferSize; - } - protected int getBufferSize() { return mBufferSize; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/SocketCallback.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/SocketCallback.java index e8aeaae5eb..a5e23b2bb0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/SocketCallback.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/SocketCallback.java @@ -26,8 +26,6 @@ public interface SocketCallback { /** * Read data from InputStream of BluetoothSocket - * - * @param data */ void onSocketRead(byte[] data); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/WriteAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/WriteAction.java index 1125ea41ca..3c1f724ec1 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/WriteAction.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/WriteAction.java @@ -30,7 +30,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.btbr.BtBRAction; /** * Invokes a write operation on a given socket. * The result status will be made available asynchronously through the - * {@link SocketCallback} + * {@link nodomain.freeyourgadget.gadgetbridge.service.btbr.SocketCallback} */ public class WriteAction extends BtBRAction { private static final Logger LOG = LoggerFactory.getLogger(WriteAction.class); @@ -59,7 +59,7 @@ public class WriteAction extends BtBRAction { protected boolean writeValue(byte[] value) { if (LOG.isDebugEnabled()) { - LOG.debug("writing to socket: " + Logging.formatBytes(value)); + LOG.debug("writing to socket: {}", Logging.formatBytes(value)); } try { mOutputStream.write(value); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java index c9fd689b6d..7ed89e9116 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java @@ -485,6 +485,11 @@ public class BleNamesResolver { mServices.put("0000185a-0000-1000-8000-00805f9b34fb", "Industrial Measurement Device"); mServices.put("0000185b-0000-1000-8000-00805f9b34fb", "Ranging"); + mServices.put("00001101-0000-1000-8000-00805f9b34fb", "SPP (Serial Port Profile)"); + mServices.put("0000111e-0000-1000-8000-00805f9b34fb", "HFP HS (Hands-Free Profile)"); + mServices.put("0000110b-0000-1000-8000-00805f9b34fb", "A2DP Sink"); + mServices.put("0000110e-0000-1000-8000-00805f9b34fb", "AVRCP Remote"); + mServices.put("0000fdab-0000-1000-8000-00805f9b34fb", "(Propr: Xiaomi Proximity Unlock Service)"); mServices.put("0000fe95-0000-1000-8000-00805f9b34fb", "(Propr: Xiaomi Wear Service)"); mServices.put("0000fee0-0000-3512-2118-0009af100700", "(Propr: Xiaomi MiLi Service)"); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/aawireless/AAWirelessSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/aawireless/AAWirelessSupport.java index 2923d9e152..072f548743 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/aawireless/AAWirelessSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/aawireless/AAWirelessSupport.java @@ -70,9 +70,8 @@ public class AAWirelessSupport extends AbstractBTBRDeviceSupport { private final BroadcastReceiver commandReceiver = new AAWirelessCommandReceiver(); public AAWirelessSupport() { - super(LOG); + super(LOG, MAX_MTU); addSupportedService(UUID_SERVICE_AAWIRELESS); - setBufferSize(MAX_MTU); } @Override diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsBtbrSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsBtbrSupport.java index c6479d40cf..7c6c9a7af3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsBtbrSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsBtbrSupport.java @@ -101,9 +101,8 @@ public class ZeppOsBtbrSupport extends AbstractBTBRDeviceSupport implements Zepp private long lastWrite = -1; public ZeppOsBtbrSupport() { - super(LOG); + super(LOG, MAX_MTU); addSupportedService(HuamiService.UUID_BT_SERIAL_SERVICE); - setBufferSize(MAX_MTU); } @Override diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HonorBRSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HonorBRSupport.java index 3baae57fe5..2ad15b0434 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HonorBRSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HonorBRSupport.java @@ -17,17 +17,11 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.huawei; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants; public class HonorBRSupport extends HuaweiBRSupport { - private static final Logger LOG = LoggerFactory.getLogger(HonorBRSupport.class); public HonorBRSupport() { super(); addSupportedService(HuaweiConstants.UUID_SERVICE_HONOR_SDP); - setBufferSize(1032); } - } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiBRSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiBRSupport.java index dfc5348e27..487d9baffc 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiBRSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiBRSupport.java @@ -49,9 +49,8 @@ public class HuaweiBRSupport extends AbstractBTBRDeviceSupport { private final HuaweiSupportProvider supportProvider; public HuaweiBRSupport() { - super(LOG); + super(LOG, 1032); addSupportedService(HuaweiConstants.UUID_SERVICE_HUAWEI_SDP); - setBufferSize(1032); supportProvider = new HuaweiSupportProvider(this); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiFreebudsSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiFreebudsSupport.java index e7b3db6a3c..e77c307947 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiFreebudsSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiFreebudsSupport.java @@ -56,7 +56,6 @@ public class HuaweiFreebudsSupport extends HuaweiBRSupport implements HeadphoneH public HuaweiFreebudsSupport() { super(); addSupportedService(UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")); - setBufferSize(1032); } @Override diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzArguments.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzArguments.kt new file mode 100644 index 0000000000..f1ef4b5ce1 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzArguments.kt @@ -0,0 +1,74 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.shokz + +/// Naming: LONG_PRESS_MULTI_FUNCTION __ SIMULTANEOUS_VOLUME_UP_DOWN +enum class ShokzControls(val code: Int) { + ASSISTANT__MEDIA_SOURCE(0x01), + MEDIA_SOURCE__ASSISTANT(0x02), + MEDIA_SOURCE__MEDIA_SOURCE(0x03), + ASSISTANT__ASSISTANT(0x04), + ; + + companion object { + fun fromPreference(value: String): ShokzControls? = entries.find { it.name == value.uppercase() } + fun fromCode(code: Int): ShokzControls? = entries.find { it.code == code } + } +} + +enum class ShokzEqualizer(val code: Int) { + STANDARD(0x01), + VOCAL(0x02), + SWIMMING(0x07), + ; + + companion object { + fun fromPreference(value: String): ShokzEqualizer? = entries.find { it.name == value.uppercase() } + fun fromCode(code: Int): ShokzEqualizer? = entries.find { it.code == code } + } +} + +enum class ShokzMp3PlaybackMode(val code: Int) { + NORMAL(0x00), + SHUFFLE(0x01), + REPEAT(0x02), + ; + + companion object { + fun fromPreference(value: String): ShokzMp3PlaybackMode? = entries.find { it.name == value.uppercase() } + fun fromCode(code: Int): ShokzMp3PlaybackMode? = entries.find { it.code == code } + } +} + +enum class ShokzMediaSource(val code: Int) { + BLUETOOTH(0x00), + MP3(0x01), + ; + + companion object { + fun fromPreference(value: String): ShokzMediaSource? = entries.find { it.name == value.uppercase() } + fun fromCode(code: Int): ShokzMediaSource? = entries.find { it.code == code } + } +} + +enum class ShokzPlaybackStatus(val code: Int) { + PAUSED(0x00), + PLAYING(0xff), + ; + + companion object { + fun fromPreference(value: String): ShokzPlaybackStatus? = entries.find { it.name == value.uppercase() } + fun fromCode(code: Int): ShokzPlaybackStatus? = entries.find { it.code == code } + } +} + +enum class ShokzLanguage(val locale: String, val code: Int) { + ENGLISH("en", 0x00), + CHINESE("zh", 0x01), + JAPANESE("ja", 0x02), + KOREAN("ko", 0x03), + ; + + companion object { + fun fromLocale(locale: String): ShokzLanguage? = entries.find { it.locale == locale } + fun fromCode(code: Int): ShokzLanguage? = entries.find { it.code == code } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzCommand.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzCommand.kt new file mode 100644 index 0000000000..7106283f3d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzCommand.kt @@ -0,0 +1,75 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.shokz + +enum class ShokzCommand( + val group: Int, + val code: Int +) { + BATTERY_GET(0x02, 0x0003), + BATTERY_RET(0x02, 0x8003), + CONTROLS_ACK(0x01, 0x8024), + CONTROLS_GET(0x02, 0x001b), + CONTROLS_RET(0x02, 0x801b), + CONTROLS_SET(0x01, 0x0024), + BUTTON_NEXT_ACK(0x01, 0x8008), + BUTTON_NEXT(0x01, 0x0008), + BUTTON_PAUSE_ACK(0x01, 0x8004), + BUTTON_PAUSE(0x01, 0x0004), + BUTTON_PLAY_ACK(0x01, 0x8003), + BUTTON_PLAY(0x01, 0x0003), + BUTTON_PREV_ACK(0x01, 0x8007), + BUTTON_PREV(0x01, 0x0007), + EQ_UNK_ACK_1(0x01, 0x800e), + EQUALIZER_ACK(0x03, 0x0009), + EQUALIZER_GET(0x02, 0x0008), + EQUALIZER_RET(0x02, 0x8008), + EQUALIZER_SET(0x01, 0x000e), + FIRMWARE_GET(0x02, 0x0002), + FIRMWARE_RET(0x02, 0x8002), + LANGUAGE_ACK(0x01, 0x8002), + LANGUAGE_GET(0x02, 0x0006), + LANGUAGE_RET(0x02, 0x8006), + LANGUAGE_SET(0x01, 0x0002), + MEDIA_SOURCE_ACK(0x01, 0x8025), + MEDIA_SOURCE_NOTIFY(0x03, 0x000c), + MEDIA_SOURCE_SET(0x01, 0x0025), + MODE_BLUETOOTH_UNK_0(0x03, 0x000d), + MEDIA_SOURCE_GET(0x02, 0x001c), + MEDIA_SOURCE_RET(0x02, 0x801c), + MODE_MP3_ACK(0x02, 0x801e), + MODE_MP3_SET(0x02, 0x001e), + MP3_PLAYBACK_MODE_ACK(0x01, 0x8026), + MP3_PLAYBACK_MODE_GET(0x02, 0x001d), + MP3_PLAYBACK_MODE_RET(0x02, 0x801d), + MP3_PLAYBACK_MODE_SET(0x01, 0x0026), + MULTIPOINT_CONNECT_ACK(0x01, 0x800b), + MULTIPOINT_CONNECT_REQ(0x01, 0x000b), + MULTIPOINT_DEVICE_CONNECTION_NOTIFY(0x03, 0x000b), + MULTIPOINT_DEVICES_GET(0x02, 0x0004), + MULTIPOINT_DEVICES_RET(0x02, 0x8004), + MULTIPOINT_DISCONNECT_ACK(0x01, 0x800c), + MULTIPOINT_DISCONNECT_REQ(0x01, 0x000c), + MULTIPOINT_OFF_ACK(0x01, 0x8010), + MULTIPOINT_OFF(0x01, 0x0010), + MULTIPOINT_ON_ACK(0x01, 0x8011), + MULTIPOINT_ON(0x01, 0x0011), + MULTIPOINT_PAIR_SECOND_FINISH(0x01, 0x0023), + MULTIPOINT_START_PAIR_ACK(0x01, 0x8022), + MULTIPOINT_START_PAIR_REQ(0x01, 0x001d), + PLAYBACK_STATUS_GET(0x02, 0x0005), + PLAYBACK_STATUS_NOTIFY(0x03, 0x0006), + PLAYBACK_STATUS_RET(0x02, 0x8005), + MULTIPOINT_GET(0x02, 0x0010), + MULTIPOINT_RET(0x02, 0x8010), + VOLUME_ACK(0x01, 0x800a), + VOLUME_GET(0x02, 0x0007), + VOLUME_NOTIFY(0x03, 0x0005), + VOLUME_RET(0x02, 0x8007), + VOLUME_SET(0x01, 0x000a), + ; + + companion object { + fun getCommand(group: Int, code: Int): ShokzCommand? = entries.find { + it.group == group && it.code == code + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzMessage.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzMessage.kt new file mode 100644 index 0000000000..68b4c9c12a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzMessage.kt @@ -0,0 +1,24 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.shokz + +data class ShokzMessage( + val command: ShokzCommand, + val args: ByteArray = byteArrayOf() +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ShokzMessage + + if (command != other.command) return false + if (!args.contentEquals(other.args)) return false + + return true + } + + override fun hashCode(): Int { + var result = command.hashCode() + result = 31 * result + args.contentHashCode() + return result + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzSupport.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzSupport.kt new file mode 100644 index 0000000000..eb46d4a24c --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzSupport.kt @@ -0,0 +1,626 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.shokz + +import android.os.Handler +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice +import nodomain.freeyourgadget.gadgetbridge.service.AbstractHeadphoneBTBRDeviceSupport +import nodomain.freeyourgadget.gadgetbridge.service.btbr.TransactionBuilder +import nodomain.freeyourgadget.gadgetbridge.util.CheckSums +import nodomain.freeyourgadget.gadgetbridge.util.kotlin.stringUntilNullTerminator +import org.slf4j.LoggerFactory +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.LinkedList +import java.util.Queue +import java.util.UUID +import kotlin.math.floor + +class ShokzSupport : AbstractHeadphoneBTBRDeviceSupport(LOG, MAX_MTU) { + private val packetBuffer: ByteBuffer = ByteBuffer.allocate(MAX_MTU).order(ByteOrder.LITTLE_ENDIAN) + + private val messageQueue: Queue = LinkedList() + private var pendingMessage: ShokzMessage? = null + private var timeoutRetries = 0 + private val timeoutHandler = Handler() + + init { + addSupportedService(UUID_SERVICE_SHOKZ) + } + + override fun useAutoConnect(): Boolean { + return true + } + + override fun initializeDevice(builder: TransactionBuilder): TransactionBuilder { + packetBuffer.clear() + timeoutHandler.removeCallbacksAndMessages(null) + messageQueue.clear() + pendingMessage = null + timeoutRetries = 0 + + builder.setDeviceState(GBDevice.State.INITIALIZING) + + // Send the fw version request directly, but queue everything else, otherwise the device does not respond to all + builder.write(*encodeCommand(ShokzCommand.FIRMWARE_GET)) + + pendingMessage = ShokzMessage(ShokzCommand.FIRMWARE_GET) + timeoutHandler.postDelayed({ onCommandTimeout() }, 2000L) + queueCommand(ShokzCommand.MEDIA_SOURCE_GET) + queueCommand(ShokzCommand.BATTERY_GET) + queueCommand(ShokzCommand.EQUALIZER_GET) + queueCommand(ShokzCommand.PLAYBACK_STATUS_GET) + queueCommand(ShokzCommand.VOLUME_GET) + queueCommand(ShokzCommand.MP3_PLAYBACK_MODE_GET) + queueCommand(ShokzCommand.CONTROLS_GET) + queueCommand(ShokzCommand.LANGUAGE_GET) + + return builder + } + + override fun dispose() { + synchronized(ConnectionMonitor) { + timeoutHandler.removeCallbacksAndMessages(null) + super.dispose() + } + } + + override fun onSocketRead(data: ByteArray) { + packetBuffer.put(data) + packetBuffer.flip() + + while (packetBuffer.hasRemaining()) { + packetBuffer.mark() + + if (packetBuffer.remaining() < 10) { + // not enough bytes for min packet + packetBuffer.reset() + break + } + + val preamble = packetBuffer.getShort() + if (preamble != PACKET_PREAMBLE) { + LOG.warn("Unexpected byte 0x{} is not preamble, skipping 2b", preamble.toHexString()) + continue + } + + val packetLength = packetBuffer.getShort() + if (packetBuffer.remaining() < 4 + packetLength) { + // not enough bytes for packetIdx + packet + packetBuffer.reset() + break + } + + packetBuffer.get().toInt() // always 1? + packetBuffer.get().toInt() // isAck? + packetBuffer.getShort() // always 0? + val payloadLength = packetBuffer.getShort().toInt() and 0xffff + + if (packetBuffer.remaining() < 2 + payloadLength) { + // not enough bytes for payload + crc + packetBuffer.reset() + break + } + + val crc = packetBuffer.getShort().toInt() and 0xffff + val payload = ByteArray(payloadLength) + packetBuffer.get(payload) + + val expectedCrc = CheckSums.crc16_maxim(payload, 0, payload.size) + if (crc != expectedCrc) { + LOG.warn("Invalid CRC: got 0x{}, expected 0x{}", crc.toHexString(), expectedCrc.toHexString()) + continue + } + + var sendNext: Boolean + try { + sendNext = handlePayload(payload) + } catch (e: Exception) { + LOG.error("Failed to handle payload", e) + sendNext = true + } + + if (sendNext) { + sendNextCommand() + } + } + + packetBuffer.compact() + } + + override fun onSendConfiguration(config: String) { + when (config) { + DeviceSettingsPreferenceConst.PREF_LANGUAGE -> setLanguage() + DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_BLUETOOTH, + DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_MP3 -> setEqualizer(config) + + DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE -> setMediaSource() + DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE -> setMediaPlaybackMode() + DeviceSettingsPreferenceConst.PREF_SHOKZ_CONTROLS_LONG_PRESS_MULTI_FUNCTION, + DeviceSettingsPreferenceConst.PREF_SHOKZ_CONTROLS_SIMULTANEOUS_VOLUME_UP_DOWN -> setControls() + + else -> super.onSendConfiguration(config) + } + } + + private fun setLanguage() { + val localeString: String = devicePrefs.getString("language", "en") + val language = ShokzLanguage.fromLocale(localeString) ?: run { + LOG.warn("Unknown language {}, falling back to english", localeString) + ShokzLanguage.ENGLISH + } + LOG.info("Setting language to {}", language) + + queueCommand( + ShokzCommand.LANGUAGE_SET, + byteArrayOf(language.code.toByte(), 0x00, 0x00, 0x00) + ) + } + + private fun setEqualizer(config: String) { + val value: String = devicePrefs.getString(config, "standard") + val equalizer = ShokzEqualizer.fromPreference(value) ?: run { + LOG.warn("Unknown equalizer {}, falling back to standard", value) + ShokzEqualizer.STANDARD + } + + LOG.info("Setting equalizer to {}", equalizer) + + val args = when (equalizer) { + ShokzEqualizer.STANDARD, + ShokzEqualizer.SWIMMING -> { + byteArrayOf( + equalizer.code.toByte(), 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00 + ) + } + + ShokzEqualizer.VOCAL -> { + byteArrayOf( + equalizer.code.toByte(), 0xfc.toByte(), 0x00, 0x03, + 0x02, 0x02, 0x00, 0x00 + ) + } + } + queueCommand(ShokzCommand.EQUALIZER_SET, args) + } + + private fun setMediaSource() { + val value: String = devicePrefs.getString(DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE, "bluetooth") + val mediaSource = ShokzMediaSource.fromPreference(value) ?: run { + LOG.warn("Unknown media source {}, falling back to bluetooth", value) + ShokzMediaSource.BLUETOOTH + } + + LOG.info("Setting media source to {}", mediaSource) + + queueCommand( + ShokzCommand.MEDIA_SOURCE_SET, + byteArrayOf(mediaSource.code.toByte(), 0x00, 0x00, 0x00) + ) + } + + private fun setMediaPlaybackMode() { + val value: String = devicePrefs.getString(DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE, "normal") + val playbackMode = ShokzMp3PlaybackMode.fromPreference(value) ?: run { + LOG.warn("Unknown playback mode {}, falling back to normal", value) + ShokzMp3PlaybackMode.NORMAL + } + + LOG.info("Setting playback mode to {}", playbackMode) + + queueCommand( + ShokzCommand.MP3_PLAYBACK_MODE_SET, + byteArrayOf(playbackMode.code.toByte(), 0x00, 0x00, 0x00) + ) + } + + private fun setControls() { + val valueLongPress: String = devicePrefs.getString( + DeviceSettingsPreferenceConst.PREF_SHOKZ_CONTROLS_LONG_PRESS_MULTI_FUNCTION, + "standard" + ) + val valueVolumeUpDown: String = devicePrefs.getString( + DeviceSettingsPreferenceConst.PREF_SHOKZ_CONTROLS_SIMULTANEOUS_VOLUME_UP_DOWN, + "standard" + ) + + val controls = when { + valueLongPress == "assistant" && valueVolumeUpDown == "media_source" -> ShokzControls.ASSISTANT__MEDIA_SOURCE + valueLongPress == "media_source" && valueVolumeUpDown == "assistant" -> ShokzControls.MEDIA_SOURCE__ASSISTANT + valueLongPress == "media_source" && valueVolumeUpDown == "media_source" -> ShokzControls.MEDIA_SOURCE__MEDIA_SOURCE + valueLongPress == "assistant" && valueVolumeUpDown == "assistant" -> ShokzControls.ASSISTANT__ASSISTANT + else -> { + LOG.warn( + "Unknown controls {}/{}, falling back to {}", + valueLongPress, valueVolumeUpDown, + ShokzControls.ASSISTANT__MEDIA_SOURCE + ) + ShokzControls.ASSISTANT__MEDIA_SOURCE + } + } + + LOG.info("Setting controls to {}", controls) + + queueCommand( + ShokzCommand.CONTROLS_SET, + byteArrayOf(controls.code.toByte(), 0x00, 0x00, 0x00) + ) + } + + private fun handlePayload(payload: ByteArray): Boolean { + if (LOG.isTraceEnabled) { + LOG.trace("Processing payload: {}", payload.toHexString()) + } + + val buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN) + val unk1 = buf.getInt() // 0x01 + if (unk1 != 0x01) { + LOG.warn("Unexpected unk1 0x{}", unk1.toHexString()) + return true + } + val len = buf.getInt() + if (len != buf.remaining()) { + LOG.warn("Unexpected number of bytes in payload: got 0x{}, expected 0x{}", buf.remaining(), len) + return true + } + val unk2 = buf.getInt() // 0x02 + if (unk2 != 0x02) { + LOG.warn("Unexpected unk2 0x{}", unk2.toHexString()) + return true + } + val unk3 = buf.getInt() // 0x04 + if (unk3 != 0x04) { + LOG.warn("Unexpected unk3 0x{}", unk3.toHexString()) + return true + } + val group = buf.getInt() + val unk4 = buf.getInt() // 0x03 + if (unk4 != 0x03) { + LOG.warn("Unexpected unk4 0x{}", unk4.toHexString()) + return true + } + val unk5 = buf.getInt() // 0x04 + if (unk5 != 0x04) { + LOG.warn("Unexpected unk5 0x{}", unk5.toHexString()) + return true + } + val code = buf.getInt() + val unk6 = buf.getInt() // 0x04 + if (unk6 != 0x04) { + LOG.warn("Unexpected unk6 0x{}", unk6.toHexString()) + return true + } + val argsLength = buf.getInt() + val args = ByteArray(argsLength) + buf.get(args) + + val command = ShokzCommand.getCommand(group, code) + if (command == null) { + LOG.warn("Unknown command: group 0x{}, code 0x{}", group.toHexString(), code.toHexString()) + return true + } + + handleCommand(command, args) + + return pendingMessage?.let { msg -> + (msg.command.code or MASK_RESPONSE) == command.code + } ?: false + } + + private fun handleCommand(command: ShokzCommand, args: ByteArray) { + val buf = ByteBuffer.wrap(args).order(ByteOrder.LITTLE_ENDIAN) + + LOG.debug("Handling command {} args={}", command, args.toHexString()) + + when (command) { + ShokzCommand.FIRMWARE_RET -> { + val zero = buf.get().toInt() + if (zero != 0) { + LOG.warn("Unexpected non-zero byte 0x{} for {}", zero.toHexString(), command) + return + } + val firmware = buf.stringUntilNullTerminator() + if (firmware == null) { + LOG.warn("Failed to get firmware from payload") + return + } + LOG.info("Got firmware: {}", firmware) + + val versionInfoEvent = GBDeviceEventVersionInfo() + versionInfoEvent.fwVersion = firmware + evaluateGBDeviceEvent(versionInfoEvent) + + gbDevice.setUpdateState(GBDevice.State.INITIALIZED, context) + } + + ShokzCommand.MEDIA_SOURCE_RET -> { + val zero = buf.get().toInt() + if (zero != 0) { + LOG.warn("Unexpected non-zero byte 0x{} for {}", zero.toHexString(), command) + return + } + val code = buf.get().toInt() and 0xff + val mode = ShokzMediaSource.fromCode(code) + if (mode == null) { + LOG.warn("Unknown mode code 0x{}", code.toHexString()) + return + } + + LOG.info("Got mode: {}", mode) + + evaluateGBDeviceEvent( + GBDeviceEventUpdatePreferences( + DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE, + mode.name.lowercase() + ) + ) + } + + ShokzCommand.BATTERY_RET -> { + val zero = buf.get().toInt() + if (zero != 0) { + LOG.warn("Unexpected non-zero byte 0x{} for {}", zero.toHexString(), command) + return + } + val batteryPercentage = ((buf.get().toInt() and 0xff) + 1) * 10 + + LOG.info("Got battery: {}%", batteryPercentage) + + val batteryInfoEvent = GBDeviceEventBatteryInfo() + batteryInfoEvent.level = batteryPercentage + evaluateGBDeviceEvent(batteryInfoEvent) + } + + ShokzCommand.EQUALIZER_RET, ShokzCommand.EQUALIZER_ACK -> { + if (command == ShokzCommand.EQUALIZER_RET) { + val zero = buf.get().toInt() + if (zero != 0) { + LOG.warn("Unexpected non-zero byte 0x{} for {}", zero.toHexString(), command) + return + } + } + + val code = buf.get().toInt() and 0xff + val equalizer = ShokzEqualizer.fromCode(code) + if (equalizer == null) { + LOG.warn("Unknown equalizer code 0x{}", code.toHexString()) + return + } + + LOG.info("Got equalizer: {}", equalizer) + + val mediaSourceValue: String = + devicePrefs.getString(DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE, "bluetooth") + val mediaSource = ShokzMediaSource.fromPreference(mediaSourceValue) ?: run { + LOG.warn("Unknown media source {}, falling back to bluetooth", mediaSourceValue) + ShokzMediaSource.BLUETOOTH + } + + devicePrefs.getString(DeviceSettingsPreferenceConst.PREF_LANGUAGE, "en") + evaluateGBDeviceEvent( + GBDeviceEventUpdatePreferences( + when (mediaSource) { + ShokzMediaSource.BLUETOOTH -> DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_BLUETOOTH + ShokzMediaSource.MP3 -> DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_MP3 + }, + equalizer.name.lowercase() + ) + ) + } + + ShokzCommand.PLAYBACK_STATUS_RET -> { + val zero = buf.get().toInt() + if (zero != 0) { + LOG.warn("Unexpected non-zero byte 0x{} for {}", zero.toHexString(), command) + return + } + val code = buf.get().toInt() and 0xff + val playbackStatus = ShokzPlaybackStatus.fromCode(code) + if (playbackStatus == null) { + LOG.warn("Unknown playback status code 0x{}", code.toHexString()) + return + } + + LOG.info("Got playback status: {}", playbackStatus) + + // TODO handle + } + + ShokzCommand.VOLUME_RET -> { + val zero = buf.get().toInt() + if (zero != 0) { + LOG.warn("Unexpected non-zero byte 0x{} for {}", zero.toHexString(), command) + return + } + val percentage = floor(((buf.get().toInt() and 0xff) / 16) * 100 + 0.5) + + LOG.info("Got volume: {}", percentage) + + // TODO handle + } + + ShokzCommand.MP3_PLAYBACK_MODE_RET -> { + val zero = buf.get().toInt() + if (zero != 0) { + LOG.warn("Unexpected non-zero byte 0x{} for {}", zero.toHexString(), command) + return + } + val code = buf.get().toInt() and 0xff + val playbackMode = ShokzMp3PlaybackMode.fromCode(code) + if (playbackMode == null) { + LOG.warn("Unknown mp3 playback mode code 0x{}", code.toHexString()) + return + } + + LOG.info("Got mp3 playback mode: {}", playbackMode) + + evaluateGBDeviceEvent( + GBDeviceEventUpdatePreferences( + DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE, + playbackMode.name.lowercase() + ) + ) + } + + ShokzCommand.CONTROLS_RET -> { + val zero = buf.get().toInt() + if (zero != 0) { + LOG.warn("Unexpected non-zero byte 0x{} for {}", zero.toHexString(), command) + return + } + val code = buf.get().toInt() and 0xff + val controls = ShokzControls.fromCode(code) + if (controls == null) { + LOG.warn("Unknown controls code 0x{}", code.toHexString()) + return + } + + LOG.info("Got controls: {}", controls) + + val eventUpdatePreferences = GBDeviceEventUpdatePreferences() + + eventUpdatePreferences.withPreference( + DeviceSettingsPreferenceConst.PREF_SHOKZ_CONTROLS_LONG_PRESS_MULTI_FUNCTION, + when (controls) { + ShokzControls.ASSISTANT__MEDIA_SOURCE, ShokzControls.ASSISTANT__ASSISTANT -> "assistant" + ShokzControls.MEDIA_SOURCE__ASSISTANT, ShokzControls.MEDIA_SOURCE__MEDIA_SOURCE -> "media_source" + } + ).withPreference( + DeviceSettingsPreferenceConst.PREF_SHOKZ_CONTROLS_SIMULTANEOUS_VOLUME_UP_DOWN, + when (controls) { + ShokzControls.ASSISTANT__MEDIA_SOURCE, ShokzControls.MEDIA_SOURCE__MEDIA_SOURCE -> "media_source" + ShokzControls.MEDIA_SOURCE__ASSISTANT, ShokzControls.ASSISTANT__ASSISTANT -> "assistant" + } + ) + + evaluateGBDeviceEvent(eventUpdatePreferences) + } + + ShokzCommand.LANGUAGE_RET -> { + buf.getShort() // 0 + val code = buf.get().toInt() and 0xff + val language = ShokzLanguage.fromCode(code) + if (language == null) { + LOG.warn("Unknown language code 0x{}", code.toHexString()) + return + } + + LOG.info("Got language: {}", language) + + evaluateGBDeviceEvent( + GBDeviceEventUpdatePreferences( + DeviceSettingsPreferenceConst.PREF_LANGUAGE, + language.locale + ) + ) + } + + ShokzCommand.MEDIA_SOURCE_NOTIFY -> { + val code = buf.get().toInt() and 0xff + val mediaSource = ShokzMediaSource.fromCode(code) + if (mediaSource == null) { + LOG.warn("Unknown media source 0x{}", code.toHexString()) + return + } + + LOG.info("Got media source change: {}", mediaSource) + + evaluateGBDeviceEvent( + GBDeviceEventUpdatePreferences( + DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE, + mediaSource.name.lowercase() + ) + ) + } + + else -> LOG.warn("Unhandled command {}, args={}", command, args.toHexString()) + } + } + + private fun queueCommand( + command: ShokzCommand, + args: ByteArray = byteArrayOf() + ) { + messageQueue.add(ShokzMessage(command, args)) + + if (pendingMessage == null) { + sendNextCommand() + } + } + + private fun onCommandTimeout() { + if (timeoutRetries++ < 3) { + LOG.warn("Timed out waiting for response, retrying attempt {}", timeoutRetries) + pendingMessage?.let { msg -> + sendMessage(msg) + return + } + } + LOG.warn("Timed out waiting for response, giving up") + sendNextCommand() + } + + private fun sendNextCommand() { + timeoutHandler.removeCallbacksAndMessages(null) + timeoutRetries = 0 + + pendingMessage = messageQueue.poll() + pendingMessage?.let { msg -> + LOG.debug("Sending next command in queue: {}", msg.command) + sendMessage(msg) + return + } + LOG.debug("No more commands in the queue") + } + + private fun sendMessage(message: ShokzMessage) { + val builder = createTransactionBuilder(message.command.name.lowercase()) + builder.write(*encodeCommand(message.command, message.args)) + builder.queue() + + timeoutHandler.postDelayed({ onCommandTimeout() }, 2000L) + } + + companion object { + private val LOG = LoggerFactory.getLogger(ShokzSupport::class.java) + private const val MAX_MTU: Int = 2048 + private const val PACKET_PREAMBLE: Short = 0x5aa5.toShort() // LE + private const val MASK_RESPONSE: Int = 0x8000 + + // This does not show up in the discovered services? + private val UUID_SERVICE_SHOKZ: UUID = UUID.fromString("0000fef0-0000-1000-8000-00805f9b34fb") + + /// Each command seems to have the following format (all u32 LE) + /// 0x01 length 0x02 0x04 group 0x03 0x04 command 0x04 args_length args + fun encodeCommand( + command: ShokzCommand, + args: ByteArray = byteArrayOf() + ): ByteArray { + val buf = ByteBuffer.allocate(52 + args.size).order(ByteOrder.LITTLE_ENDIAN) + buf.putShort(PACKET_PREAMBLE) + buf.putShort((buf.limit() - 8).toShort()) + buf.putInt(0x01) + buf.putShort((buf.limit() - 12).toShort()) + // CRC will be added later + buf.position(buf.position() + 2) + buf.putInt(0x01) + buf.putInt(buf.limit() - 20) + buf.putInt(0x02) + buf.putInt(0x04) + buf.putInt(command.group) + buf.putInt(0x03) + buf.putInt(0x04) + buf.putInt(command.code) + buf.putInt(0x04) + buf.putInt(args.size) + buf.put(args) + + buf.putShort(10, CheckSums.crc16_maxim(buf.array(), 12, buf.limit() - 12).toShort()) + + return buf.array() + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppSupport.java index b54260f810..f8382c6cc1 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppSupport.java @@ -46,7 +46,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.GB; public class XiaomiSppSupport extends XiaomiConnectionSupport { private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppSupport.class); - AbstractBTBRDeviceSupport commsSupport = new AbstractBTBRDeviceSupport(LOG) { + AbstractBTBRDeviceSupport commsSupport = new AbstractBTBRDeviceSupport(LOG, 1024) { @Override public boolean useAutoConnect() { return mXiaomiSupport.useAutoConnect(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/CheckSums.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/CheckSums.java index d6e42705c2..db2fe27ccc 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/CheckSums.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/CheckSums.java @@ -59,7 +59,7 @@ public class CheckSums { public static int getCRC16(byte[] seq) { return getCRC16(seq, 0xFFFF); } - + public static int getCRC16(byte[] seq, int crc) { for (byte b : seq) { crc = ((crc >>> 8) | (crc << 8)) & 0xffff; @@ -71,7 +71,7 @@ public class CheckSums { crc &= 0xffff; return crc; } - + public static int getCRC16ansi(byte[] seq) { int crc = 0xffff; int polynomial = 0xA001; @@ -190,4 +190,24 @@ public class CheckSums { md.update(str.getBytes(StandardCharsets.UTF_8)); return GB.hexdump(md.digest()).toLowerCase(Locale.ROOT); } + + public static int crc16_maxim(final byte[] data, final int offset, final int length) { + // Reflected polynomial 0x8005 -> 0xA001 when shifting right (LSB first) + final int poly = 0xA001; + int crc = 0x0000; + + for (int i = offset; i < offset + length; i++) { + final byte b = data[i]; + crc ^= (b & 0xFF); + for (int j = 0; j < 8; j++) { + if ((crc & 0x0001) != 0) { + crc = (crc >>> 1) ^ poly; + } else { + crc >>>= 1; + } + } + } + + return crc ^ 0xFFFF; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/kotlin/ByteArrayExtensions.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/kotlin/ByteArrayExtensions.kt new file mode 100644 index 0000000000..dcf8fe8a56 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/kotlin/ByteArrayExtensions.kt @@ -0,0 +1,13 @@ +package nodomain.freeyourgadget.gadgetbridge.util.kotlin + +fun ByteArray.startsWith(prefix: ByteArray): Boolean { + if (prefix.size > this.size) { + return false + } + for (i in prefix.indices) { + if (this[i] != prefix[i]) { + return false + } + } + return true +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/kotlin/ByteBufferExtensions.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/kotlin/ByteBufferExtensions.kt new file mode 100644 index 0000000000..32b14cacff --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/kotlin/ByteBufferExtensions.kt @@ -0,0 +1,8 @@ +package nodomain.freeyourgadget.gadgetbridge.util.kotlin + +import nodomain.freeyourgadget.gadgetbridge.util.StringUtils +import java.nio.ByteBuffer + +fun ByteBuffer.stringUntilNullTerminator(): String? { + return StringUtils.untilNullTerminator(this) +} diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index e3566efc36..7303ac01ad 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -2708,6 +2708,7 @@ @string/bahasa_melayu @string/persian + @string/chinese @string/english @string/korean @string/japanese @@ -2773,6 +2774,7 @@ ms_MY fa_IR + zh en ko ja @@ -4119,6 +4121,53 @@ custom_2 + + @string/menuitem_bluetooth + @string/mp3 + + + bluetooth + mp3 + + + + @string/equalizer_preset_standard + @string/sony_equalizer_preset_vocal + + + standard + vocal + + + + @string/equalizer_preset_standard + @string/Swimming + + + standard + swimming + + + + @string/media_playback_mode_normal + @string/media_playback_mode_shuffle + @string/media_playback_mode_repeat + + + normal + shuffle + repeat + + + + @string/moondrop_touch_action_assistant + @string/media_source_switching_bluetooth_mp3 + + + assistant + media_source + + @string/sony_automatic_power_off_off @string/sony_automatic_power_off_5_min diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 631b3e9f25..8d6e8138b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1208,6 +1208,7 @@ Manual Chinese (Simplified) Chinese (Traditional) + Chinese English English (Australia) English (Canada) @@ -4205,4 +4206,15 @@ Help Could not open URL Authentication settings + Shokz OpenSwim Pro + Standard + Playback mode + Normal + Shuffle + Repeat + Media source + MP3 + Switch media source (Bluetooth/MP3) + Long-press the multi-function button + Long-press volume up and down simultaneously diff --git a/app/src/main/res/xml/devicesettings_media_source.xml b/app/src/main/res/xml/devicesettings_media_source.xml new file mode 100644 index 0000000000..58dc37a8ba --- /dev/null +++ b/app/src/main/res/xml/devicesettings_media_source.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/xml/devicesettings_playback_mode.xml b/app/src/main/res/xml/devicesettings_playback_mode.xml new file mode 100644 index 0000000000..2882610f7c --- /dev/null +++ b/app/src/main/res/xml/devicesettings_playback_mode.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/xml/devicesettings_shokz_controls.xml b/app/src/main/res/xml/devicesettings_shokz_controls.xml new file mode 100644 index 0000000000..46e6814b27 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_shokz_controls.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/app/src/main/res/xml/devicesettings_shokz_equalizer.xml b/app/src/main/res/xml/devicesettings_shokz_equalizer.xml new file mode 100644 index 0000000000..3160d3828b --- /dev/null +++ b/app/src/main/res/xml/devicesettings_shokz_equalizer.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzSupportTest.kt b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzSupportTest.kt new file mode 100644 index 0000000000..10cf16a3d8 --- /dev/null +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzSupportTest.kt @@ -0,0 +1,14 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.shokz + +import org.junit.Assert.* +import org.junit.Test + +class ShokzSupportTest { + @Test + fun testEncodeCommand() { + assertEquals( + "a55a2c000100000028005ab701000000200000000200000004000000020000000300000004000000020000000400000000000000", + ShokzSupport.encodeCommand(ShokzCommand.FIRMWARE_GET).toHexString() + ) + } +}