Sinilink: Initial support

Developed and tested against the XY-AP50L
This commit is contained in:
José Rebelo
2026-05-17 09:13:29 +01:00
parent c1f5647b6f
commit 6961ff77db
18 changed files with 909 additions and 35 deletions
+1
View File
@@ -157,6 +157,7 @@
<w>shahrabani</w>
<w>shimokawa</w>
<w>shokz</w>
<w>sinilink</w>
<w>sint</w>
<w>skype</w>
<w>slezak</w>
@@ -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";
@@ -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);
@@ -888,7 +888,7 @@ public class GBDeviceAdapterv2 extends ListAdapter<GBDevice, GBDeviceAdapterv2.V
}
final String label = action.getLabel(device, context);
if (!StringUtils.isEmpty(label)) {
if (!StringUtils.isNullOrEmpty(label)) {
holder.customActions[i].label.setVisibility(View.VISIBLE);
holder.customActions[i].label.setText(label);
} else {
@@ -34,6 +34,7 @@ import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
public class PasswordCapabilityImpl {
public static final String PREF_SCREEN_PASSWORD = "pref_screen_password";
public static final String PREF_PASSWORD = "pref_password";
public static final String PREF_PASSWORD_ENABLED = "pref_password_enabled";
@@ -42,6 +43,7 @@ public class PasswordCapabilityImpl {
NUMBERS_4_DIGITS_0_TO_9,
NUMBERS_4_DIGITS_1_TO_4,
NUMBERS_6,
VISIBLE_NUMBERS_4_DIGITS_0_TO_9,
}
public void registerPreferences(final Context context, final Mode mode, final DeviceSpecificSettingsHandler handler) {
@@ -62,6 +64,7 @@ public class PasswordCapabilityImpl {
password.setSummary(R.string.prefs_password_6_digits_0_to_9_summary);
break;
case NUMBERS_4_DIGITS_0_TO_9:
case VISIBLE_NUMBERS_4_DIGITS_0_TO_9:
password.setSummary(R.string.prefs_password_4_digits_0_to_9_summary);
break;
case NUMBERS_4_DIGITS_1_TO_4:
@@ -71,43 +74,49 @@ public class PasswordCapabilityImpl {
break;
}
password.setOnBindEditTextListener(new EditTextPreference.OnBindEditTextListener() {
@Override
public void onBindEditText(@NonNull final EditText editText) {
final int expectedLength;
final List<InputFilter> inputFilters = new ArrayList<>();
password.setOnBindEditTextListener(editText -> {
final int expectedLength;
final List<InputFilter> 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));
});
}
@@ -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<out DeviceSupport?> {
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<DeviceCardAction> {
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)
}
}
)
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<Preference>(PREF_SCREEN_PASSWORD)?.summary = null
handler.findPreference<Preference>(PREF_PASSWORD_ENABLED)?.summary = null
handler.findPreference<EditTextPreference>(DeviceSettingsPreferenceConst.PREF_DEVICE_NAME)?.let {
it.setOnBindEditTextListener { editText: EditText? ->
editText!!.setFilters(arrayOf<InputFilter>(LengthFilter(10)))
}
}
}
override fun getPreferenceKeysWithSummary(): Set<String> {
return setOf()
}
}
@@ -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),
@@ -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");
@@ -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 }
}
}
@@ -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
}
}
}
@@ -0,0 +1,33 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="45sp"
android:height="45sp"
android:viewportWidth="30"
android:viewportHeight="30">
<path
android:fillColor="?attr/deviceIconLight"
android:pathData="M3.871 3.877h20.925a0.947 0.947 0 0 1 0.948 0.947v20.01a0.947 0.947 0 0 1-0.948 0.948H3.871a0.947 0.947 0 0 1-0.947-0.948V4.824a0.947 0.947 0 0 1 0.947-0.947z"
android:strokeWidth="3.57115" />
<path
android:fillColor="?attr/deviceIconDark"
android:pathData="M3.879 3.035h20.925a0.947 0.947 0 0 1 0.947 0.947v20.01a0.947 0.947 0 0 1-0.947 0.948H3.88a0.947 0.947 0 0 1-0.947-0.948V3.982A0.947 0.947 0 0 1 3.88 3.035z"
android:strokeWidth="3.57115" />
<path
android:fillColor="?attr/deviceIconPrimary"
android:pathData="M3.871 3.413h20.925a0.947 0.947 0 0 1 0.948 0.947v20.01a0.947 0.947 0 0 1-0.948 0.948H3.871a0.947 0.947 0 0 1-0.947-0.948V4.36A0.947 0.947 0 0 1 3.87 3.413z"
android:strokeWidth="3.57115" />
<!-- Speaker body + cone, centered horizontally at ~14.3, vertically at 15 -->
<path
android:fillColor="?attr/deviceIconOnPrimary"
android:pathData="M 6,11 L 11,11 L 16,7 L 16,23 L 11,19 L 6,19 Z"
android:strokeWidth="1" />
<!-- Small sound wave crescent (outer r=3, inner r=2.2, centered at 17.5,15) -->
<path
android:fillColor="?attr/deviceIconOnPrimary"
android:pathData="M 17.5,12 A 3,3 0 0 1 17.5,18 L 17.5,17.2 A 2.2,2.2 0 0 0 17.5,12.8 Z"
android:strokeWidth="1" />
<!-- Medium sound wave crescent (outer r=5, inner r=4.2, centered at 17.5,15) -->
<path
android:fillColor="?attr/deviceIconOnPrimary"
android:pathData="M 17.5,10 A 5,5 0 0 1 17.5,20 L 17.5,19.2 A 4.2,4.2 0 0 0 17.5,10.8 Z"
android:strokeWidth="1" />
</vector>
@@ -0,0 +1,25 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M660,720v-480h80v480h-80ZM220,720v-480l360,240 -360,240ZM300,480ZM300,570 L436,480 300,390v180Z" />
</vector>
@@ -0,0 +1,25 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M220,720v-480h80v480h-80ZM740,720L380,480l360,-240v480ZM660,480ZM660,570v-180l-136,90 136,90Z" />
</vector>
+47
View File
@@ -5712,4 +5712,51 @@
<item>MUSIC_CONTROL</item>
<item>RING_PHONE</item>
</string-array>
<string-array name="sinilink_equalizer_names">
<item>@string/pref_title_equalizer_normal</item>
<item>@string/nothing_equalizer_rock</item>
<item>@string/nothing_equalizer_pop</item>
<item>@string/nothing_equalizer_classical</item>
<item>@string/soundcore_equalizer_preset_jazz</item>
<item>@string/equalizer_preset_country</item>
</string-array>
<string-array name="sinilink_equalizer_values">
<item>normal</item>
<item>rock</item>
<item>pop</item>
<item>classic</item>
<item>jazz</item>
<item>country</item>
</string-array>
<string-array name="sinilink_playback_mode_names">
<item>@string/sinilink_playback_mode_list_cycle</item>
<item>@string/sinilink_playback_mode_single_head</item>
<item>@string/sinilink_playback_mode_single_cycle</item>
<item>@string/sinilink_playback_mode_random</item>
<item>@string/sinilink_playback_mode_order</item>
</string-array>
<string-array name="sinilink_playback_mode_values">
<item>list_cycle</item>
<item>single_head</item>
<item>single_cycle</item>
<item>random</item>
<item>order</item>
</string-array>
<string-array name="sinilink_media_source_names">
<item>@string/media_source_tf</item>
<item>@string/media_source_usb</item>
<item>@string/menuitem_bluetooth</item>
<item>@string/media_source_audio_card</item>
<item>@string/media_source_aux</item>
</string-array>
<string-array name="sinilink_media_source_values">
<item>tf</item>
<item>usb</item>
<item>bluetooth</item>
<item>audio_card</item>
<item>aux</item>
</string-array>
</resources>
+12
View File
@@ -3544,6 +3544,7 @@
<string name="soundcore_equalizer_preset_lounge">Lounge</string>
<string name="soundcore_equalizer_preset_piano">Piano</string>
<string name="soundcore_equalizer_preset_rnb">R&amp;B</string>
<string name="equalizer_preset_country">Country</string>
<string name="soundcore_equalizer_preset_small_speakers">Small Speakers</string>
<string name="soundcore_equalizer_preset_spoken_word">Spoken Word</string>
<string name="soundcore_equalizer_preset_treble_reducer">Treble Reducer</string>
@@ -4630,6 +4631,7 @@
<string name="cannot_open_help_url">Could not open URL</string>
<string name="auth_settings">Authentication settings</string>
<string name="devicetype_shokz_openswim_pro" translatable="false">Shokz OpenSwim Pro</string>
<string name="devicetype_sinilink" translatable="false">Sinilink</string>
<string name="devicetype_onetouch" translatable="false">OneTouch Glucose Meter</string>
<string name="equalizer_preset_standard">Standard</string>
<string name="media_playback_mode">Playback mode</string>
@@ -4895,4 +4897,14 @@
<string name="broadcast_heart_rate_summary">Any nearby device or app can pick up your heart rate.</string>
<string name="dual_connection_title">Dual connection</string>
<string name="dual_connection_summary">Allow two apps or devices to connect simultaneously.</string>
<string name="sinilink_playback_mode_list_cycle">List cycle</string>
<string name="sinilink_playback_mode_single_head">Single (head)</string>
<string name="sinilink_playback_mode_single_cycle">Single cycle</string>
<string name="sinilink_playback_mode_random">Random</string>
<string name="sinilink_playback_mode_order">Order</string>
<string name="media_source_tf">TF card</string>
<string name="media_source_usb">USB</string>
<string name="media_source_audio_card">Audio card</string>
<string name="media_source_aux">AUX</string>
<string name="sinilink_prompt_tone">Prompt tone</string>
</resources>
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ListPreference
android:defaultValue="bluetooth"
android:entries="@array/sinilink_media_source_names"
android:entryValues="@array/sinilink_media_source_values"
android:icon="@drawable/ic_music_note"
android:key="pref_media_source"
android:title="@string/media_source"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:defaultValue="normal"
android:entries="@array/sinilink_equalizer_names"
android:entryValues="@array/sinilink_equalizer_values"
android:icon="@drawable/ic_equalizer"
android:key="pref_headphones_equalizer"
android:title="@string/prefs_equalizer_preset"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:defaultValue="list_cycle"
android:entries="@array/sinilink_playback_mode_names"
android:entryValues="@array/sinilink_playback_mode_values"
android:icon="@drawable/ic_play"
android:key="pref_media_playback_mode"
android:title="@string/media_playback_mode"
app:useSimpleSummaryProvider="true" />
<SeekBarPreference
android:defaultValue="15"
android:icon="@drawable/ic_volume_up"
android:key="volume"
android:max="30"
android:title="@string/menuitem_volume"
app:showSeekBarValue="true" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:icon="@drawable/ic_notifications"
android:key="prompt_tone"
android:title="@string/sinilink_prompt_tone" />
</androidx.preference.PreferenceScreen>
@@ -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