mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Sinilink: Initial support
Developed and tested against the XY-AP50L
This commit is contained in:
Generated
+1
@@ -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>
|
||||
|
||||
+1
@@ -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";
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+17
-8
@@ -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,35 +74,42 @@ public class PasswordCapabilityImpl {
|
||||
break;
|
||||
}
|
||||
|
||||
password.setOnBindEditTextListener(new EditTextPreference.OnBindEditTextListener() {
|
||||
@Override
|
||||
public void onBindEditText(@NonNull final EditText editText) {
|
||||
password.setOnBindEditTextListener(editText -> {
|
||||
final int expectedLength;
|
||||
final List<InputFilter> inputFilters = new ArrayList<>();
|
||||
|
||||
int inputType;
|
||||
|
||||
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);
|
||||
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);
|
||||
editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
|
||||
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);
|
||||
editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
|
||||
inputType = 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);
|
||||
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));
|
||||
}
|
||||
@@ -107,7 +117,6 @@ public class PasswordCapabilityImpl {
|
||||
editText.setSelection(editText.getText().length());
|
||||
editText.setFilters(inputFilters.toArray(new InputFilter[0]));
|
||||
editText.addTextChangedListener(new ExpectedLengthTextWatcher(editText, expectedLength));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+144
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+65
@@ -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),
|
||||
|
||||
+3
@@ -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");
|
||||
|
||||
+67
@@ -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 }
|
||||
}
|
||||
}
|
||||
+392
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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&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>
|
||||
+1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user