diff --git a/.idea/dictionaries/t.xml b/.idea/dictionaries/t.xml
index 98024442c2..49c6fad8be 100644
--- a/.idea/dictionaries/t.xml
+++ b/.idea/dictionaries/t.xml
@@ -157,6 +157,7 @@
shahrabani
shimokawa
shokz
+ sinilink
sint
skype
slezak
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 0242d425f1..b7bc61d515 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
@@ -170,6 +170,7 @@ public class DeviceSettingsPreferenceConst {
public static final String PREF_ALWAYS_ON_DISPLAY_STYLE = "always_on_display_style";
public static final String PREF_VOLUME = "volume";
+ public static final String PREF_PROMPT_TONE = "prompt_tone";
public static final String PREF_CROWN_VIBRATION = "crown_vibration";
public static final String PREF_ALERT_TONE = "alert_tone";
public static final String PREF_COVER_TO_MUTE = "cover_to_mute";
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 2537ddeeae..bf4d8d838c 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
@@ -1001,6 +1001,7 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
addPreferenceHandlerFor(PREF_VOLUME);
addPreferenceHandlerFor(PREF_CROWN_VIBRATION);
+ addPreferenceHandlerFor(PREF_PROMPT_TONE);
addPreferenceHandlerFor(PREF_ALERT_TONE);
addPreferenceHandlerFor(PREF_COVER_TO_MUTE);
addPreferenceHandlerFor(PREF_VIBRATE_FOR_ALERT);
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java
index a8d0a1e315..74e1726a02 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java
@@ -888,7 +888,7 @@ public class GBDeviceAdapterv2 extends ListAdapter inputFilters = new ArrayList<>();
+ password.setOnBindEditTextListener(editText -> {
+ final int expectedLength;
+ final List inputFilters = new ArrayList<>();
- switch (mode) {
- case NUMBERS_6:
- password.setSummary(R.string.prefs_password_6_digits_0_to_9_summary);
- editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
- expectedLength = 6;
- break;
- case NUMBERS_4_DIGITS_0_TO_9:
- password.setSummary(R.string.prefs_password_4_digits_0_to_9_summary);
- editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
- expectedLength = 4;
- break;
- case NUMBERS_4_DIGITS_1_TO_4:
- password.setSummary(R.string.prefs_password_4_digits_1_to_4_summary);
- editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
- expectedLength = 4;
- inputFilters.add(new InputFilter_Digits_1to4());
- break;
- default:
- editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
- expectedLength = -1;
- break;
- }
+ int inputType;
- if (expectedLength != -1) {
- inputFilters.add(new InputFilter.LengthFilter(expectedLength));
- }
-
- editText.setSelection(editText.getText().length());
- editText.setFilters(inputFilters.toArray(new InputFilter[0]));
- editText.addTextChangedListener(new ExpectedLengthTextWatcher(editText, expectedLength));
+ switch (mode) {
+ case NUMBERS_6:
+ password.setSummary(R.string.prefs_password_6_digits_0_to_9_summary);
+ inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD;
+ expectedLength = 6;
+ break;
+ case NUMBERS_4_DIGITS_0_TO_9:
+ password.setSummary(R.string.prefs_password_4_digits_0_to_9_summary);
+ inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD;
+ expectedLength = 4;
+ break;
+ case VISIBLE_NUMBERS_4_DIGITS_0_TO_9:
+ password.setSummary(R.string.prefs_password_4_digits_0_to_9_summary);
+ inputType = InputType.TYPE_CLASS_NUMBER;
+ expectedLength = 4;
+ break;
+ case NUMBERS_4_DIGITS_1_TO_4:
+ password.setSummary(R.string.prefs_password_4_digits_1_to_4_summary);
+ inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD;
+ expectedLength = 4;
+ inputFilters.add(new InputFilter_Digits_1to4());
+ break;
+ default:
+ inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD;
+ expectedLength = -1;
+ break;
}
+
+ editText.setInputType(inputType);
+
+ if (expectedLength != -1) {
+ inputFilters.add(new InputFilter.LengthFilter(expectedLength));
+ }
+
+ editText.setSelection(editText.getText().length());
+ editText.setFilters(inputFilters.toArray(new InputFilter[0]));
+ editText.addTextChangedListener(new ExpectedLengthTextWatcher(editText, expectedLength));
});
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sinilink/SinilinkCoordinator.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sinilink/SinilinkCoordinator.kt
new file mode 100644
index 0000000000..583e4b8952
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sinilink/SinilinkCoordinator.kt
@@ -0,0 +1,144 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.sinilink
+
+import android.content.Context
+import nodomain.freeyourgadget.gadgetbridge.GBApplication
+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.capabilities.password.PasswordCapabilityImpl
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCardAction
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sinilink.SinilinkButton
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sinilink.SinilinkPlaybackState
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sinilink.SinilinkSupport
+import java.util.regex.Pattern
+
+class SinilinkCoordinator : AbstractBLEDeviceCoordinator() {
+ override fun getSupportedDeviceName(): Pattern? {
+ return Pattern.compile("^Sinilink-APP$")
+ }
+
+ override fun getManufacturer(): String {
+ return "Xinyi Electronics"
+ }
+
+ override fun getDeviceSupportClass(device: GBDevice): Class {
+ return SinilinkSupport::class.java
+ }
+
+ override fun getBondingStyle(): Int {
+ // Does not seem to be needed?
+ return BONDING_STYLE_ASK
+ }
+
+ override fun suggestUnbindBeforePair(): Boolean {
+ return false
+ }
+
+ override fun getDeviceNameResource(): Int {
+ return R.string.devicetype_sinilink
+ }
+
+ override fun getDefaultIconResource(): Int {
+ return R.drawable.ic_device_speaker
+ }
+
+ override fun getDeviceKind(device: GBDevice): DeviceCoordinator.DeviceKind? {
+ return DeviceCoordinator.DeviceKind.SPEAKER
+ }
+
+ override fun getBatteryCount(device: GBDevice): Int {
+ return 0
+ }
+
+ override fun getDeviceSpecificSettings(device: GBDevice): DeviceSpecificSettings {
+ val settings = DeviceSpecificSettings()
+
+ settings.addRootScreen(R.xml.devicesettings_sinilink)
+ settings.addRootScreen(R.xml.devicesettings_device_name)
+ settings.addRootScreen(R.xml.devicesettings_password)
+
+ settings.addConnectedPreferences(
+ DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE,
+ DeviceSettingsPreferenceConst.PREF_HEADPHONES_EQUALIZER,
+ DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE,
+ DeviceSettingsPreferenceConst.PREF_VOLUME,
+ DeviceSettingsPreferenceConst.PREF_PROMPT_TONE,
+ DeviceSettingsPreferenceConst.PREF_DEVICE_NAME,
+ PasswordCapabilityImpl.PREF_SCREEN_PASSWORD,
+ PasswordCapabilityImpl.PREF_PASSWORD_ENABLED,
+ PasswordCapabilityImpl.PREF_PASSWORD,
+ )
+
+ return settings
+ }
+
+ override fun getDeviceSpecificSettingsCustomizer(device: GBDevice): DeviceSpecificSettingsCustomizer {
+ return SinilinkSettingsCustomizer()
+ }
+
+ override fun getPasswordCapability(): PasswordCapabilityImpl.Mode {
+ return PasswordCapabilityImpl.Mode.VISIBLE_NUMBERS_4_DIGITS_0_TO_9
+ }
+
+ override fun getCustomActions(): List {
+ return DEVICE_CARD_ACTIONS
+ }
+
+ companion object {
+ private val DEVICE_CARD_ACTIONS = listOf(
+ // previous
+ object : DeviceCardAction {
+ override fun getIcon(device: GBDevice): Int {
+ return R.drawable.ic_skip_previous
+ }
+
+ override fun getDescription(device: GBDevice, context: Context): String {
+ return context.getString(R.string.pref_media_previous)
+ }
+
+ override fun onClick(device: GBDevice, context: Context) {
+ GBApplication.deviceService(device).onSendConfiguration(SinilinkButton.PREVIOUS.name)
+ }
+ },
+
+ // play / pause
+ object : DeviceCardAction {
+ override fun getIcon(device: GBDevice): Int {
+ return when (SinilinkPlaybackState.fromPreference(device.getExtraInfo("playback_state") as? String ?: return R.drawable.ic_play)) {
+ SinilinkPlaybackState.PLAYING -> R.drawable.ic_pause
+ else -> R.drawable.ic_play
+ }
+ }
+
+ override fun getDescription(device: GBDevice, context: Context): String {
+ return context.getString(R.string.moondrop_touch_action_play_pause)
+ }
+
+ override fun onClick(device: GBDevice, context: Context) {
+ GBApplication.deviceService(device).onSendConfiguration(SinilinkButton.PLAY_PAUSE.name)
+ }
+ },
+
+ // next
+ object : DeviceCardAction {
+ override fun getIcon(device: GBDevice): Int {
+ return R.drawable.ic_skip_next
+ }
+
+ override fun getDescription(device: GBDevice, context: Context): String {
+ return context.getString(R.string.pref_media_next)
+ }
+
+ override fun onClick(device: GBDevice, context: Context) {
+ GBApplication.deviceService(device).onSendConfiguration(SinilinkButton.NEXT.name)
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sinilink/SinilinkSettingsCustomizer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sinilink/SinilinkSettingsCustomizer.kt
new file mode 100644
index 0000000000..5224a10884
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sinilink/SinilinkSettingsCustomizer.kt
@@ -0,0 +1,65 @@
+/* Copyright (C) 2026 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.sinilink
+
+import android.text.InputFilter
+import android.text.InputFilter.LengthFilter
+import android.widget.EditText
+import androidx.preference.EditTextPreference
+import androidx.preference.EditTextPreference.OnBindEditTextListener
+import androidx.preference.Preference
+import kotlinx.parcelize.Parcelize
+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.capabilities.password.PasswordCapabilityImpl.PREF_PASSWORD_ENABLED
+import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl.PREF_SCREEN_PASSWORD
+import nodomain.freeyourgadget.gadgetbridge.service.devices.earfun.prefs.EarFunSettingsPreferenceConst
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs
+
+
+@Parcelize
+class SinilinkSettingsCustomizer : DeviceSpecificSettingsCustomizer {
+ override fun onPreferenceChange(preference: Preference, handler: DeviceSpecificSettingsHandler
+ ) {
+ }
+
+ override fun onDeviceChanged(handler: DeviceSpecificSettingsHandler) {
+
+ }
+
+ override fun customizeSettings(
+ handler: DeviceSpecificSettingsHandler,
+ genericDevicePrefs: Prefs,
+ rootKey: String?
+ ) {
+ // Override the default summary if it is not a band or a watch
+ handler.findPreference(PREF_SCREEN_PASSWORD)?.summary = null
+ handler.findPreference(PREF_PASSWORD_ENABLED)?.summary = null
+
+ handler.findPreference(DeviceSettingsPreferenceConst.PREF_DEVICE_NAME)?.let {
+ it.setOnBindEditTextListener { editText: EditText? ->
+ editText!!.setFilters(arrayOf(LengthFilter(10)))
+ }
+ }
+ }
+
+ override fun getPreferenceKeysWithSummary(): Set {
+ return setOf()
+ }
+
+}
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 c2796a1f1c..1081d577ca 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
@@ -405,6 +405,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.sbm_67.SanitasSBM67Coordinat
import nodomain.freeyourgadget.gadgetbridge.devices.sbm_67.SilverCrestSBM67Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.scannable.ScannableDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.shokz.ShokzOpenSwimProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.sinilink.SinilinkCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.smaq2oss.SMAQ2OSSCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.soflow.SoFlowCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyLinkBudsCoordinator;
@@ -819,6 +820,7 @@ public enum DeviceType {
SONY_WH_CH720N(SonyWHCH720NCoordinator.class),
PIXEL_BUDS_A(PixelBudsACoordinator.class),
SHOKZ_OPENSWIM_PRO(ShokzOpenSwimProCoordinator.class),
+ SINILINK(SinilinkCoordinator.class),
ONETOUCH(OneTouchCoordinator.class),
SOUNDCORE_LIBERTY3_PRO(SoundcoreLiberty3ProCoordinator.class),
SOUNDCORE_LIBERTY4_NC(SoundcoreLiberty4NCCoordinator.class),
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 13325463a6..784648dccc 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
@@ -606,6 +606,7 @@ public class BleNamesResolver {
mServices.put("8d53dc1d-1db7-4cd3-868b-8a527460aa84", "(Propr: SMP - Simple Management Protocol)");
mServices.put("6e40fff0-b5a3-f393-e0a9-e50e24dcca9e", "(Propr: NUS - Nordic UART Service)");
mServices.put("de5bf728-d711-4e47-af26-65e3012a5dc7", "(Propr: Yawell Serial)");
+ mServices.put("0000ae00-0000-1000-8000-00805f9b34fb", "(Propr: Sinilink-APP)");
// source https://bitbucket.org/bluetooth-SIG/public/src/main/assigned_numbers/uuids/characteristic_uuids.yaml
mCharacteristics.put("00002a00-0000-1000-8000-00805f9b34fb", "Device Name");
@@ -1202,6 +1203,8 @@ public class BleNamesResolver {
mCharacteristics.put("6e400003-b5a3-f393-e0a9-e50e24dcca9e", "(Propr: Nordic UART RX)");
mCharacteristics.put("de5bf729-d711-4e47-af26-65e3012a5dc7", "(Propr: Yawell Notify)");
mCharacteristics.put("de5bf72a-d711-4e47-af26-65e3012a5dc7", "(Propr: Yawell Write)");
+ mCharacteristics.put("0000ae04-0000-1000-8000-00805f9b34fb", "(Propr: Sinilink-APP RX)");
+ mCharacteristics.put("0000ae10-0000-1000-8000-00805f9b34fb", "(Propr: Sinilink-APP TX)");
mValueFormats.put(52, "32bit float");
mValueFormats.put(50, "16bit float");
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sinilink/SinilinkArguments.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sinilink/SinilinkArguments.kt
new file mode 100644
index 0000000000..6988901a56
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sinilink/SinilinkArguments.kt
@@ -0,0 +1,67 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.sinilink
+
+enum class SinilinkEqualizer(val code: Int) {
+ NORMAL(0x09),
+ ROCK(0x0a),
+ POP(0x0b),
+ CLASSIC(0x0c),
+ JAZZ(0x0d),
+ COUNTRY(0x0e),
+ ;
+
+ companion object {
+ fun fromPreference(value: String): SinilinkEqualizer? = entries.find { it.name == value.uppercase() }
+ fun fromCode(code: Int): SinilinkEqualizer? = entries.find { it.code == code }
+ }
+}
+
+enum class SinilinkPlaybackMode(val code: Int) {
+ SINGLE_HEAD(0x10),
+ SINGLE_CYCLE(0x11),
+ RANDOM(0x12),
+ ORDER(0x13),
+ LIST_CYCLE(0x0f),
+ ;
+
+ companion object {
+ fun fromPreference(value: String): SinilinkPlaybackMode? = entries.find { it.name == value.uppercase() }
+ fun fromCode(code: Int): SinilinkPlaybackMode? = entries.find { it.code == code }
+ }
+}
+
+enum class SinilinkPlaybackState(val code: Int) {
+ IDLE(0x01),
+ PLAYING(0x02),
+ ;
+
+ companion object {
+ fun fromPreference(value: String): SinilinkPlaybackState? = entries.find { it.name == value.uppercase() }
+ fun fromCode(code: Int): SinilinkPlaybackState? = entries.find { it.code == code }
+ }
+}
+
+enum class SinilinkMediaSource(val code: Int) {
+ TF(0x03),
+ USB(0x04),
+ BLUETOOTH(0x14),
+ AUDIO_CARD(0x15),
+ AUX(0x16),
+ ;
+
+ companion object {
+ fun fromPreference(value: String): SinilinkMediaSource? = entries.find { it.name == value.uppercase() }
+ fun fromCode(code: Int): SinilinkMediaSource? = entries.find { it.code == code }
+ }
+}
+
+enum class SinilinkButton(val code: Int) {
+ PLAY_PAUSE(0x01),
+ PREVIOUS(0x07),
+ NEXT(0x08),
+ ;
+
+ companion object {
+ fun fromPreference(value: String): SinilinkButton? = entries.find { it.name == value.uppercase() }
+ fun fromCode(code: Int): SinilinkButton? = entries.find { it.code == code }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sinilink/SinilinkSupport.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sinilink/SinilinkSupport.kt
new file mode 100644
index 0000000000..befc359744
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sinilink/SinilinkSupport.kt
@@ -0,0 +1,392 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.sinilink
+
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGattCharacteristic
+import androidx.core.text.isDigitsOnly
+import nodomain.freeyourgadget.gadgetbridge.GBApplication
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst
+import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport
+import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sinilink.SinilinkSupport.Companion.UUID_CHARACTERISTIC_SINILINK_RX
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs
+import org.slf4j.LoggerFactory
+import java.nio.ByteBuffer
+import java.util.UUID
+
+class SinilinkSupport : AbstractBTLESingleDeviceSupport(LOG) {
+ private var gotName = false
+ private var gotFirmware = false
+ private var gotStatus = false
+ private var gotEvent = false
+
+ init {
+ addSupportedService(UUID_SERVICE_SINILINK)
+ }
+
+ override fun useAutoConnect(): Boolean = true
+
+ override fun initializeDevice(builder: TransactionBuilder): TransactionBuilder {
+ gotName = false
+ gotFirmware = false
+ gotStatus = false
+ gotEvent = false
+
+ builder.setDeviceState(GBDevice.State.INITIALIZING)
+
+ builder.notify(UUID_CHARACTERISTIC_SINILINK_RX, true)
+
+ // Request version
+ builder.write(UUID_CHARACTERISTIC_SINILINK_TX, *encodeFrame(byteArrayOf(CMD_VERSION.toByte())))
+
+ // Request status (volume, prompt tone, password)
+ builder.write(UUID_CHARACTERISTIC_SINILINK_TX, *encodeFrame(byteArrayOf(CMD_STATUS_GET.toByte())))
+
+ // Read the TX characteristic, this triggers an event (?)
+ builder.read(UUID_CHARACTERISTIC_SINILINK_TX)
+
+ // Request name
+ builder.write(UUID_CHARACTERISTIC_SINILINK_TX, *encodeFrame(byteArrayOf(CMD_NAME_GET.toByte())))
+
+ // Will be set to initialized once we receive the settings
+
+ return builder
+ }
+
+ override fun onCharacteristicChanged(
+ gatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ value: ByteArray,
+ ): Boolean {
+ if (characteristic.uuid == UUID_CHARACTERISTIC_SINILINK_RX) {
+ parseFrame(value)
+ return true
+ }
+
+ return super.onCharacteristicChanged(gatt, characteristic, value)
+ }
+
+ override fun onCharacteristicRead(
+ gatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ value: ByteArray,
+ status: Int
+ ): Boolean {
+ if (status == BluetoothGatt.GATT_SUCCESS && characteristic.uuid == UUID_CHARACTERISTIC_SINILINK_TX) {
+ parseFrame(value)
+ return true
+ }
+
+ return super.onCharacteristicRead(gatt, characteristic, value, status)
+ }
+
+ private fun parseFrame(frame: ByteArray) {
+ if (frame.size < 5) {
+ LOG.warn("Frame too short: {}", frame.size)
+ return
+ }
+
+ if (frame[0].toInt() and 0xff != FRAME_SOF) {
+ LOG.warn("Unexpected SOF: 0x{}", Integer.toHexString(frame[0].toInt() and 0xff))
+ return
+ }
+
+ val declaredLen = frame[1].toInt() and 0xff
+ if (declaredLen != frame.size) {
+ LOG.warn("Length mismatch: declared={}, actual={}", declaredLen, frame.size)
+ return
+ }
+
+ val checksumHiIdx = frame.size - 2
+ val calculated = frame.slice(0 until checksumHiIdx).sumOf { it.toInt() and 0xff } % 65536
+ val received = ((frame[checksumHiIdx].toInt() and 0xff) shl 8) or (frame[checksumHiIdx + 1].toInt() and 0xff)
+ if (calculated != received) {
+ LOG.warn(
+ "Checksum mismatch: calculated=0x{}, received=0x{}",
+ Integer.toHexString(calculated),
+ Integer.toHexString(received),
+ )
+ return
+ }
+
+ val payload = frame.sliceArray(2 until checksumHiIdx)
+ if (payload.isEmpty()) {
+ LOG.warn("Empty payload")
+ return
+ }
+
+ try {
+ handlePayload(payload)
+ } catch (e: Exception) {
+ LOG.error("Failed to handle payload", e)
+ }
+
+ if (gotName && gotStatus && gotFirmware && gotEvent && !device.isInitialized) {
+ device.state = GBDevice.State.INITIALIZED
+ device.sendDeviceUpdateIntent(context)
+ }
+ }
+
+ private fun handlePayload(payload: ByteArray) {
+ LOG.debug("Got payload: {}", payload.toHexString())
+
+ when (val cmd = payload[0].toInt() and 0xff) {
+ CMD_STATUS_GET -> handleStatus(payload)
+ CMD_EVENT_NOTIFICATION_1, CMD_EVENT_NOTIFICATION_2 -> handleEvent(payload)
+ CMD_VERSION -> handleVersion(payload)
+ CMD_NAME_GET -> handleName(payload)
+ else -> {
+ LOG.debug("Unhandled notification command: 0x{}", Integer.toHexString(cmd))
+ }
+ }
+ }
+
+ private fun handleStatus(payload: ByteArray) {
+ if (payload.size != 12) {
+ LOG.warn("Status payload has unexpected size: {}", payload.size)
+ return
+ }
+
+ val promptTone = payload[1].toInt() and 0xff != 0
+ val passwordEnabled = payload[2].toInt() and 0xff != 0
+ val volume = payload[3].toInt() and 0xff
+ val passwordTxt = String(payload.sliceArray(4..7), Charsets.US_ASCII)
+
+ LOG.debug(
+ "Status: volume={}, promptTone={}, passwordEnabled={}, passwordTxt={}",
+ volume,
+ promptTone,
+ passwordEnabled,
+ passwordTxt
+ )
+
+ evaluateGBDeviceEvent(
+ GBDeviceEventUpdatePreferences()
+ .withPreference(DeviceSettingsPreferenceConst.PREF_VOLUME, volume)
+ .withPreference(DeviceSettingsPreferenceConst.PREF_PROMPT_TONE, promptTone)
+ .withPreference(PasswordCapabilityImpl.PREF_PASSWORD_ENABLED, passwordEnabled)
+ .withPreference(PasswordCapabilityImpl.PREF_PASSWORD, passwordTxt)
+ )
+
+ gotStatus = true
+ }
+
+ private fun handleEvent(payload: ByteArray) {
+ if (payload.size != 11) {
+ LOG.warn("Unexpected event payload size: {}", payload.size)
+ }
+
+ val sourceCode = payload[2].toInt() and 0xff
+ val playbackStateCode = payload[3].toInt() and 0xff
+ val playbackModeCode = payload[5].toInt() and 0xff
+ val equalizerCode = payload[6].toInt() and 0xff
+
+ val source = SinilinkMediaSource.fromCode(sourceCode)
+ val playbackState = SinilinkPlaybackState.fromCode(playbackStateCode)
+ val playbackMode = SinilinkPlaybackMode.fromCode(playbackModeCode)
+ val equalizer = SinilinkEqualizer.fromCode(equalizerCode)
+
+ LOG.debug(
+ "Event: source={}, playbackState={}, playbackMode={}, equalizer={}",
+ source,
+ playbackState,
+ playbackMode,
+ equalizer
+ )
+
+ val event = GBDeviceEventUpdatePreferences()
+ source?.let { event.withPreference(DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE, it.name.lowercase()) }
+ playbackState?.let { device.setExtraInfo("playback_state", it.name) }
+ playbackMode?.let {
+ event.withPreference(
+ DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE,
+ it.name.lowercase()
+ )
+ }
+ equalizer?.let {
+ event.withPreference(
+ DeviceSettingsPreferenceConst.PREF_HEADPHONES_EQUALIZER,
+ it.name.lowercase()
+ )
+ }
+ evaluateGBDeviceEvent(event)
+
+ // Propagate device state to device card
+ device.sendDeviceUpdateIntent(context)
+
+ val version = String(payload.sliceArray(7 until 11), Charsets.US_ASCII)
+ val versionEvent = GBDeviceEventVersionInfo()
+ versionEvent.fwVersion = version
+ evaluateGBDeviceEvent(versionEvent)
+ gotEvent = true
+ }
+
+ private fun handleVersion(payload: ByteArray) {
+ if (payload.size != 5) {
+ LOG.warn("Unexpected version payload size: {}", payload.size)
+ return
+ }
+
+ val version = String(payload.sliceArray(1 until 5), Charsets.US_ASCII)
+ LOG.debug("Version: {}", version)
+ val event = GBDeviceEventVersionInfo()
+ event.fwVersion = version
+ evaluateGBDeviceEvent(event)
+ gotFirmware = true
+ }
+
+ private fun handleName(payload: ByteArray) {
+ if (payload.size < 11) {
+ LOG.warn("Unexpected name payload size: {}", payload.size)
+ return
+ }
+ val name = String(payload.sliceArray(1 until 11), Charsets.US_ASCII).trimEnd('\u0000')
+ LOG.debug("Device name: {}", name)
+ evaluateGBDeviceEvent(
+ GBDeviceEventUpdatePreferences()
+ .withPreference(DeviceSettingsPreferenceConst.PREF_DEVICE_NAME, name)
+ )
+ gotName = true
+ }
+
+ override fun onSendConfiguration(config: String) {
+ val prefs = Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.address))
+ when (config) {
+ SinilinkButton.PREVIOUS.name,
+ SinilinkButton.PLAY_PAUSE.name,
+ SinilinkButton.NEXT.name -> {
+ val button = SinilinkButton.valueOf(config)
+ LOG.debug("Sending button: {}", button)
+ sendSimpleCommand(button.code)
+ }
+
+ DeviceSettingsPreferenceConst.PREF_HEADPHONES_EQUALIZER -> {
+ val value = prefs.getString(
+ DeviceSettingsPreferenceConst.PREF_HEADPHONES_EQUALIZER,
+ SinilinkEqualizer.NORMAL.name.lowercase()
+ )
+ LOG.debug("Setting equalizer to {}", value)
+ SinilinkEqualizer.fromPreference(value)?.let { sendSimpleCommand(it.code) }
+ }
+
+ DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE -> {
+ val value = prefs.getString(
+ DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE,
+ SinilinkPlaybackMode.SINGLE_HEAD.name.lowercase()
+ )
+ LOG.debug("Setting playback mode to {}", value)
+ SinilinkPlaybackMode.fromPreference(value)?.let { sendSimpleCommand(it.code) }
+ }
+
+ DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE -> {
+ val value = prefs.getString(
+ DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE,
+ SinilinkMediaSource.BLUETOOTH.name.lowercase()
+ )
+ LOG.debug("Setting media source to {}", value)
+ SinilinkMediaSource.fromPreference(value)?.let { sendSimpleCommand(it.code) }
+ }
+
+ DeviceSettingsPreferenceConst.PREF_VOLUME -> {
+ val value = prefs.getInt(DeviceSettingsPreferenceConst.PREF_VOLUME, 10).coerceIn(0, 30)
+ LOG.debug("Setting volume to {}", value)
+ sendVolumeCommand(value)
+ }
+
+ DeviceSettingsPreferenceConst.PREF_PROMPT_TONE -> {
+ LOG.debug("Toggling prompt tone")
+ sendSimpleCommand(CMD_PROMPT_TONE_TOGGLE)
+ }
+
+ DeviceSettingsPreferenceConst.PREF_DEVICE_NAME -> {
+ val value = prefs.getString(DeviceSettingsPreferenceConst.PREF_DEVICE_NAME, "XinYi").replace("[^\\x20-\\x7e]", "")
+ LOG.debug("Setting device name to {}", value)
+ if (value.isEmpty() || value.length > 10) {
+ LOG.warn("Invalid name: {}", value)
+ return
+ }
+ LOG.debug("Setting name to {}", value)
+ sendTextCommand(CMD_NAME_SET.toByte(), value)
+ }
+
+ PasswordCapabilityImpl.PREF_PASSWORD_ENABLED -> {
+ LOG.debug("Toggling password")
+ sendSimpleCommand(CMD_PASSWORD_TOGGLE)
+ }
+
+ PasswordCapabilityImpl.PREF_PASSWORD -> {
+ val value = prefs.getString(PasswordCapabilityImpl.PREF_PASSWORD, "1234")
+ if (value.length != 4 || !value.isDigitsOnly()) {
+ LOG.warn("Invalid password: {}", value)
+ return
+ }
+ LOG.debug("Setting password to {}", value)
+ sendTextCommand(CMD_PASSWORD_SET.toByte(), value)
+ }
+
+ else -> super.onSendConfiguration(config)
+ }
+ }
+
+ private fun sendSimpleCommand(cmd: Int) {
+ val builder = createTransactionBuilder("sinilink_cmd_0x${Integer.toHexString(cmd)}")
+ builder.write(UUID_CHARACTERISTIC_SINILINK_TX, *encodeFrame(byteArrayOf(cmd.toByte())))
+ builder.queue()
+ }
+
+ private fun sendVolumeCommand(volume: Int) {
+ val payload = ByteArray(11)
+ payload[0] = CMD_SET_VOLUME.toByte()
+ payload[1] = volume.toByte()
+ val builder = createTransactionBuilder("sinilink_set_volume")
+ builder.write(UUID_CHARACTERISTIC_SINILINK_TX, *encodeFrame(payload))
+ builder.queue()
+ }
+
+ private fun sendTextCommand(command: Byte, txt: String) {
+ val buf = ByteBuffer.allocate(11)
+ buf.put(command)
+ buf.put(txt.encodeToByteArray())
+ val builder = createTransactionBuilder("sinilink_set_text")
+ builder.write(UUID_CHARACTERISTIC_SINILINK_TX, *encodeFrame(buf.array()))
+ builder.queue()
+ }
+
+ companion object {
+ private val LOG = LoggerFactory.getLogger(SinilinkSupport::class.java)
+
+ private const val FRAME_SOF = 0x7e
+
+ private const val CMD_EVENT_NOTIFICATION_1 = 0xa8
+ private const val CMD_NAME_GET = 0x20
+ private const val CMD_NAME_SET = 0x1a
+ private const val CMD_PASSWORD_SET = 0x1b
+ private const val CMD_SET_VOLUME = 0x1d
+ private const val CMD_VERSION = 0x1e
+ private const val CMD_STATUS_GET = 0x1f
+ private const val CMD_EVENT_NOTIFICATION_2 = 0xc3
+ private const val CMD_PROMPT_TONE_TOGGLE = 0x18
+ private const val CMD_PASSWORD_TOGGLE = 0x19
+
+ val UUID_SERVICE_SINILINK: UUID = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb")
+ val UUID_CHARACTERISTIC_SINILINK_RX: UUID = UUID.fromString("0000ae04-0000-1000-8000-00805f9b34fb")
+ val UUID_CHARACTERISTIC_SINILINK_TX: UUID = UUID.fromString("0000ae10-0000-1000-8000-00805f9b34fb")
+
+ /// Frame format: [0x7e][total_length][payload...][checksum_hi][checksum_lo]
+ /// Checksum = sum of all bytes before checksum, mod 65536, big-endian
+ fun encodeFrame(payload: ByteArray): ByteArray {
+ val totalLen = 2 + payload.size + 2 // SOF + len + payload + checksum(2)
+ val frame = ByteArray(totalLen)
+ frame[0] = FRAME_SOF.toByte()
+ frame[1] = totalLen.toByte()
+ payload.copyInto(frame, 2)
+ val checksum = frame.slice(0 until (totalLen - 2)).sumOf { it.toInt() and 0xff } % 65536
+ frame[totalLen - 2] = (checksum shr 8).toByte()
+ frame[totalLen - 1] = (checksum and 0xff).toByte()
+ return frame
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_device_speaker.xml b/app/src/main/res/drawable/ic_device_speaker.xml
new file mode 100644
index 0000000000..080d136030
--- /dev/null
+++ b/app/src/main/res/drawable/ic_device_speaker.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_skip_next.xml b/app/src/main/res/drawable/ic_skip_next.xml
new file mode 100644
index 0000000000..0b00d131f4
--- /dev/null
+++ b/app/src/main/res/drawable/ic_skip_next.xml
@@ -0,0 +1,25 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_skip_previous.xml b/app/src/main/res/drawable/ic_skip_previous.xml
new file mode 100644
index 0000000000..104db09a59
--- /dev/null
+++ b/app/src/main/res/drawable/ic_skip_previous.xml
@@ -0,0 +1,25 @@
+
+
+
+
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
index 0a86743c03..7a7cfb88ea 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -5712,4 +5712,51 @@
- MUSIC_CONTROL
- RING_PHONE
+
+
+ - @string/pref_title_equalizer_normal
+ - @string/nothing_equalizer_rock
+ - @string/nothing_equalizer_pop
+ - @string/nothing_equalizer_classical
+ - @string/soundcore_equalizer_preset_jazz
+ - @string/equalizer_preset_country
+
+
+ - normal
+ - rock
+ - pop
+ - classic
+ - jazz
+ - country
+
+
+
+ - @string/sinilink_playback_mode_list_cycle
+ - @string/sinilink_playback_mode_single_head
+ - @string/sinilink_playback_mode_single_cycle
+ - @string/sinilink_playback_mode_random
+ - @string/sinilink_playback_mode_order
+
+
+ - list_cycle
+ - single_head
+ - single_cycle
+ - random
+ - order
+
+
+
+ - @string/media_source_tf
+ - @string/media_source_usb
+ - @string/menuitem_bluetooth
+ - @string/media_source_audio_card
+ - @string/media_source_aux
+
+
+ - tf
+ - usb
+ - bluetooth
+ - audio_card
+ - aux
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ee42865c16..f5e9273e70 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -3544,6 +3544,7 @@
Lounge
Piano
R&B
+ Country
Small Speakers
Spoken Word
Treble Reducer
@@ -4630,6 +4631,7 @@
Could not open URL
Authentication settings
Shokz OpenSwim Pro
+ Sinilink
OneTouch Glucose Meter
Standard
Playback mode
@@ -4895,4 +4897,14 @@
Any nearby device or app can pick up your heart rate.
Dual connection
Allow two apps or devices to connect simultaneously.
+ List cycle
+ Single (head)
+ Single cycle
+ Random
+ Order
+ TF card
+ USB
+ Audio card
+ AUX
+ Prompt tone
diff --git a/app/src/main/res/xml/devicesettings_sinilink.xml b/app/src/main/res/xml/devicesettings_sinilink.xml
new file mode 100644
index 0000000000..84cd09c9a9
--- /dev/null
+++ b/app/src/main/res/xml/devicesettings_sinilink.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java
index 1c923e8d05..e6a65632c1 100644
--- a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java
+++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java
@@ -86,6 +86,7 @@ public class AbstractDeviceCoordinatorTest extends TestBase {
put("R50Pro", DeviceType.R50PRO);
put("SBM67", DeviceType.SILVERCREST_SBM_67);
put("BPM Smart", DeviceType.SANITAS_SBM_67);
+ put("Sinilink-APP", DeviceType.SINILINK); // #6040
put("R11_0500", DeviceType.YAWELL_R11); // #4711
put("Edge 2x", DeviceType.GARMIN_EDGE_25); // #5779
put("Edge 130", DeviceType.GARMIN_EDGE_130); // matrix