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