From 12b9b8a1c0cd9a15873b9968a1c01c32e7fdb982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Sun, 10 Aug 2025 00:22:24 +0100 Subject: [PATCH] Shokz: Multipoint pairing --- app/src/main/AndroidManifest.xml | 6 + .../DeviceSettingsPreferenceConst.java | 1 + .../DeviceSpecificSettingsFragment.java | 11 + .../activities/multipoint/MultipointDevice.kt | 11 + .../multipoint/MultipointDeviceAdapter.kt | 73 ++++++ .../multipoint/MultipointPairingActivity.kt | 233 +++++++++++++++++ .../devices/shokz/ShokzCoordinator.kt | 2 + .../service/devices/shokz/ShokzSupport.kt | 245 +++++++++++++++++- .../res/drawable/ic_bluetooth_disabled.xml | 10 + .../layout/activity_multipoint_pairing.xml | 95 +++++++ .../res/layout/item_multipoint_device.xml | 67 +++++ app/src/main/res/values/strings.xml | 7 + .../res/xml/devicesettings_multipoint.xml | 10 + 13 files changed, 770 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/multipoint/MultipointDevice.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/multipoint/MultipointDeviceAdapter.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/multipoint/MultipointPairingActivity.kt create mode 100644 app/src/main/res/drawable/ic_bluetooth_disabled.xml create mode 100644 app/src/main/res/layout/activity_multipoint_pairing.xml create mode 100644 app/src/main/res/layout/item_multipoint_device.xml create mode 100644 app/src/main/res/xml/devicesettings_multipoint.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7f067d79f7..1cfb50f464 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -677,6 +677,12 @@ android:launchMode="singleTop" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|layoutDirection|uiMode" android:parentActivityName=".activities.ControlCenterv2" /> + 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 b5888778c1..818ed2c392 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 @@ -562,6 +562,7 @@ public class DeviceSettingsPreferenceConst { public static final String PREFS_ACTIVITY_IN_DEVICE_CARD_DISTANCE = "prefs_activity_in_device_card_distance"; public static final String PREFS_DEVICE_CHARTS_TABS = "charts_tabs"; public static final String PREFS_PER_APP_NOTIFICATION_SETTINGS = "pref_per_app_notification_settings"; + public static final String PREF_MULTIPOINT = "pref_multipoint"; public static final String PREF_UM25_SHOW_THRESHOLD_NOTIFICATION = "um25_current_threshold_notify"; public static final String PREF_UM25_SHOW_THRESHOLD = "um25_current_threshold"; 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 11ed912317..849ad3355a 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 @@ -81,6 +81,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.app_specific_notification import nodomain.freeyourgadget.gadgetbridge.activities.audiorecordings.AudioRecordingsActivity; import nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsActivity; import nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst; +import nodomain.freeyourgadget.gadgetbridge.activities.multipoint.MultipointPairingActivity; import nodomain.freeyourgadget.gadgetbridge.activities.musicmanager.MusicManagerActivity; import nodomain.freeyourgadget.gadgetbridge.activities.widgets.WidgetScreensListActivity; import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability; @@ -1477,6 +1478,16 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i }); } + final Preference multipointPref = findPreference(PREF_MULTIPOINT); + if (multipointPref != null) { + multipointPref.setOnPreferenceClickListener(preference -> { + final Intent intent = new Intent(getContext(), MultipointPairingActivity.class); + intent.putExtra(GBDevice.EXTRA_DEVICE, getDevice()); + startActivity(intent); + return true; + }); + } + final Preference audioRecordings = findPreference("pref_key_audio_recordings"); if (audioRecordings != null) { audioRecordings.setOnPreferenceClickListener(preference -> { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/multipoint/MultipointDevice.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/multipoint/MultipointDevice.kt new file mode 100644 index 0000000000..f41f496937 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/multipoint/MultipointDevice.kt @@ -0,0 +1,11 @@ +package nodomain.freeyourgadget.gadgetbridge.activities.multipoint + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class MultipointDevice( + val address: String, + val name: String?, + val isConnected: Boolean = false +) : Parcelable diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/multipoint/MultipointDeviceAdapter.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/multipoint/MultipointDeviceAdapter.kt new file mode 100644 index 0000000000..350cb592c4 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/multipoint/MultipointDeviceAdapter.kt @@ -0,0 +1,73 @@ +package nodomain.freeyourgadget.gadgetbridge.activities.multipoint + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import nodomain.freeyourgadget.gadgetbridge.R + +class MultipointDeviceAdapter( + private val devices: List, + private val onAction: (MultipointDevice, Action) -> Unit +) : RecyclerView.Adapter() { + + var allowAction = false + + enum class Action { + CONNECT, + DISCONNECT + } + + class DeviceViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val deviceIcon: ImageView = itemView.findViewById(R.id.device_icon) + val deviceName: TextView = itemView.findViewById(R.id.device_name) + val deviceAddress: TextView = itemView.findViewById(R.id.device_address) + val connectionButton: Button = itemView.findViewById(R.id.connection_button) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DeviceViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_multipoint_device, parent, false) + return DeviceViewHolder(view) + } + + override fun onBindViewHolder(holder: DeviceViewHolder, position: Int) { + val device = devices[position] + val context = holder.itemView.context + + holder.deviceName.text = device.name ?: context.getString(R.string.unknown) + holder.deviceAddress.text = device.address + + val (icon, buttonText, action) = if (device.isConnected) { + Triple( + R.drawable.ic_bluetooth_connected, + context.getString(R.string.controlcenter_disconnect), + Action.DISCONNECT + ) + } else { + Triple( + R.drawable.ic_bluetooth_disabled, + context.getString(R.string.connect), + Action.CONNECT + ) + } + + val allowConnect = allowAction && devices.count { it.isConnected } < 2 + val allowDisconnect = allowAction + + holder.deviceIcon.setImageResource(icon) + holder.connectionButton.text = buttonText + holder.connectionButton.isEnabled = when (action) { + Action.CONNECT -> allowConnect + Action.DISCONNECT -> allowDisconnect + } + holder.connectionButton.setOnClickListener { + onAction(device, action) + } + } + + override fun getItemCount(): Int = devices.size +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/multipoint/MultipointPairingActivity.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/multipoint/MultipointPairingActivity.kt new file mode 100644 index 0000000000..3896960f43 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/multipoint/MultipointPairingActivity.kt @@ -0,0 +1,233 @@ +package nodomain.freeyourgadget.gadgetbridge.activities.multipoint + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import android.view.View +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.recyclerview.widget.LinearLayoutManager +import nodomain.freeyourgadget.gadgetbridge.R +import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity +import nodomain.freeyourgadget.gadgetbridge.databinding.ActivityMultipointPairingBinding +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice +import org.slf4j.LoggerFactory + +class MultipointPairingActivity : AbstractGBActivity() { + private lateinit var gbDevice: GBDevice + private lateinit var binding: ActivityMultipointPairingBinding + + private lateinit var deviceAdapter: MultipointDeviceAdapter + + private var devices = mutableListOf() + private var isMultipointEnabled = false + private var pairingNewDevice = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMultipointPairingBinding.inflate(layoutInflater) + setContentView(binding.root) + + gbDevice = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE) + ?: throw IllegalArgumentException("GBDevice must not be null") + + initViews() + setupRecyclerView() + updateUI() + registerBroadcastReceiver() + requestStatus() + requestDeviceList() + } + + private fun initViews() { + binding.multipointEnabled.setOnCheckedChangeListener { _, isChecked -> + if (isChecked != isMultipointEnabled) { + toggleMultipoint(isChecked) + } + } + + binding.buttonPairNewDevice.setOnClickListener { + setPairingMode(!pairingNewDevice) + } + } + + private fun setupRecyclerView() { + deviceAdapter = MultipointDeviceAdapter(devices) { device, action -> + when (action) { + MultipointDeviceAdapter.Action.CONNECT -> connectToDevice(device.address) + MultipointDeviceAdapter.Action.DISCONNECT -> disconnectFromDevice(device.address) + } + } + + binding.devicesRecyclerView.layoutManager = LinearLayoutManager(this) + binding.devicesRecyclerView.adapter = deviceAdapter + } + + private fun registerBroadcastReceiver() { + val intentFilter = IntentFilter().apply { + addAction(ACTION_MULTIPOINT_DEVICE_LIST) + addAction(ACTION_MULTIPOINT_STATUS_UPDATE) + addAction(ACTION_MULTIPOINT_PAIRING_UPDATE) + addAction(GBDevice.ACTION_DEVICE_CHANGED) + } + LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, intentFilter) + } + + override fun onDestroy() { + super.onDestroy() + setPairingMode(false) + LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver) + } + + private val broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent == null || intent.action == null) { + LOG.warn("Got null intent or action") + return + } + + val device = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE) + if (device?.address != gbDevice.address) { + LOG.warn("Got multipoint action {} for {}, but we're {}", intent.action, device, gbDevice) + return // not for this device + } + + when (intent.action) { + ACTION_MULTIPOINT_DEVICE_LIST -> { + LOG.debug("Got multipoint device list") + val deviceList = intent.getParcelableArrayListExtra(EXTRA_DEVICE_LIST) + if (deviceList != null) { + updateDeviceList(deviceList) + } + } + + ACTION_MULTIPOINT_STATUS_UPDATE -> { + LOG.debug("Got multipoint status update") + isMultipointEnabled = intent.getBooleanExtra(EXTRA_MULTIPOINT_ENABLED, false) + updateUI() + } + + ACTION_MULTIPOINT_PAIRING_UPDATE -> { + LOG.debug("Got multipoint pairing update") + pairingNewDevice = intent.getBooleanExtra(EXTRA_PAIRING_ENABLED, false) + updateUI() + } + + GBDevice.ACTION_DEVICE_CHANGED -> { + LOG.debug("Got device update") + gbDevice = device + updateUI() + } + } + } + } + + @SuppressLint("NotifyDataSetChanged") + private fun updateDeviceList(deviceList: List) { + devices.clear() + devices.addAll(deviceList) + deviceAdapter.notifyDataSetChanged() + + if (devices.isEmpty()) { + binding.devicesRecyclerView.visibility = View.GONE + binding.emptyStateLayout.visibility = View.VISIBLE + } else { + binding.devicesRecyclerView.visibility = View.VISIBLE + binding.emptyStateLayout.visibility = View.GONE + } + + binding.buttonPairNewDevice.isEnabled = + gbDevice.isInitialized && isMultipointEnabled && devices.count { it.isConnected } < 2 + } + + @SuppressLint("NotifyDataSetChanged") + private fun updateUI() { + binding.multipointEnabled.setOnCheckedChangeListener(null) + binding.multipointEnabled.isChecked = isMultipointEnabled + binding.multipointEnabled.isEnabled = gbDevice.isInitialized + binding.multipointEnabled.setOnCheckedChangeListener { _, isChecked -> + if (isChecked != isMultipointEnabled) { + toggleMultipoint(isChecked) + } + } + + binding.buttonPairNewDevice.isEnabled = + gbDevice.isInitialized && isMultipointEnabled && devices.count { it.isConnected } < 2 + binding.buttonPairNewDevice.text = if (pairingNewDevice) { + getString(R.string.bluetooth_multipoint_pair_stop) + } else { + getString(R.string.bluetooth_multipoint_pair_new) + } + + deviceAdapter.allowAction = gbDevice.isInitialized && isMultipointEnabled + deviceAdapter.notifyDataSetChanged() + } + + private fun toggleMultipoint(enable: Boolean) { + val action = if (enable) ACTION_MULTIPOINT_ENABLE else ACTION_MULTIPOINT_DISABLE + sendDeviceIntent(Intent(action)) + } + + private fun requestStatus() { + sendDeviceIntent(Intent(ACTION_MULTIPOINT_GET_STATUS)) + } + + private fun requestDeviceList() { + sendDeviceIntent(Intent(ACTION_MULTIPOINT_GET_DEVICES)) + } + + private fun setPairingMode(enabled: Boolean) { + val intent = Intent(ACTION_MULTIPOINT_START_PAIRING) + intent.putExtra(EXTRA_PAIRING_ENABLED, enabled) + sendDeviceIntent(intent) + } + + private fun connectToDevice(deviceAddress: String) { + val intent = Intent(ACTION_MULTIPOINT_CONNECT_DEVICE) + intent.putExtra(EXTRA_DEVICE_ADDRESS, deviceAddress) + sendDeviceIntent(intent) + } + + private fun disconnectFromDevice(deviceAddress: String) { + val intent = Intent(ACTION_MULTIPOINT_DISCONNECT_DEVICE) + intent.putExtra(EXTRA_DEVICE_ADDRESS, deviceAddress) + sendDeviceIntent(intent) + } + + private fun sendDeviceIntent(intent: Intent) { + intent.putExtra(GBDevice.EXTRA_DEVICE, gbDevice) + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) + } + + companion object { + private val LOG = LoggerFactory.getLogger(MultipointPairingActivity::class.java) + + const val ACTION_MULTIPOINT_DEVICE_LIST = + "nodomain.freeyourgadget.gadgetbridge.ACTION_MULTIPOINT_DEVICE_LIST" + const val ACTION_MULTIPOINT_STATUS_UPDATE = + "nodomain.freeyourgadget.gadgetbridge.ACTION_MULTIPOINT_STATUS_UPDATE" + const val ACTION_MULTIPOINT_PAIRING_UPDATE = + "nodomain.freeyourgadget.gadgetbridge.ACTION_MULTIPOINT_PAIRING_UPDATE" + const val ACTION_MULTIPOINT_ENABLE = + "nodomain.freeyourgadget.gadgetbridge.ACTION_MULTIPOINT_ENABLE" + const val ACTION_MULTIPOINT_DISABLE = + "nodomain.freeyourgadget.gadgetbridge.ACTION_MULTIPOINT_DISABLE" + const val ACTION_MULTIPOINT_GET_STATUS = + "nodomain.freeyourgadget.gadgetbridge.ACTION_MULTIPOINT_GET_STATUS" + const val ACTION_MULTIPOINT_GET_DEVICES = + "nodomain.freeyourgadget.gadgetbridge.ACTION_MULTIPOINT_GET_DEVICES" + const val ACTION_MULTIPOINT_CONNECT_DEVICE = + "nodomain.freeyourgadget.gadgetbridge.ACTION_MULTIPOINT_CONNECT_DEVICE" + const val ACTION_MULTIPOINT_DISCONNECT_DEVICE = + "nodomain.freeyourgadget.gadgetbridge.ACTION_MULTIPOINT_DISCONNECT_DEVICE" + const val ACTION_MULTIPOINT_START_PAIRING = + "nodomain.freeyourgadget.gadgetbridge.ACTION_MULTIPOINT_START_PAIRING" + + const val EXTRA_DEVICE_LIST = "device_list" + const val EXTRA_MULTIPOINT_ENABLED = "enabled" + const val EXTRA_PAIRING_ENABLED = "enabled" + const val EXTRA_DEVICE_ADDRESS = "device_address" + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/shokz/ShokzCoordinator.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/shokz/ShokzCoordinator.kt index 6b56f44d97..13b1c598b3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/shokz/ShokzCoordinator.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/shokz/ShokzCoordinator.kt @@ -40,6 +40,7 @@ abstract class ShokzCoordinator : AbstractBLClassicDeviceCoordinator() { override fun getDeviceSpecificSettings(device: GBDevice): DeviceSpecificSettings { val settings = DeviceSpecificSettings() + settings.addRootScreen(R.xml.devicesettings_multipoint) settings.addRootScreen(R.xml.devicesettings_media_source) settings.addRootScreen(R.xml.devicesettings_shokz_equalizer) settings.addRootScreen(R.xml.devicesettings_playback_mode) @@ -51,6 +52,7 @@ abstract class ShokzCoordinator : AbstractBLClassicDeviceCoordinator() { settings.addSubScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS, R.xml.devicesettings_headphones) settings.addConnectedPreferences( + DeviceSettingsPreferenceConst.PREF_MULTIPOINT, DeviceSettingsPreferenceConst.PREF_LANGUAGE, DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE, DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_BLUETOOTH, diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzSupport.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzSupport.kt index eb46d4a24c..938a51f150 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzSupport.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/shokz/ShokzSupport.kt @@ -1,7 +1,16 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.shokz +import android.bluetooth.BluetoothAdapter +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.os.Handler +import android.widget.Toast +import androidx.localbroadcastmanager.content.LocalBroadcastManager import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst +import nodomain.freeyourgadget.gadgetbridge.activities.multipoint.MultipointDevice +import nodomain.freeyourgadget.gadgetbridge.activities.multipoint.MultipointPairingActivity import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo @@ -9,6 +18,7 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice import nodomain.freeyourgadget.gadgetbridge.service.AbstractHeadphoneBTBRDeviceSupport import nodomain.freeyourgadget.gadgetbridge.service.btbr.TransactionBuilder import nodomain.freeyourgadget.gadgetbridge.util.CheckSums +import nodomain.freeyourgadget.gadgetbridge.util.GB import nodomain.freeyourgadget.gadgetbridge.util.kotlin.stringUntilNullTerminator import org.slf4j.LoggerFactory import java.nio.ByteBuffer @@ -34,6 +44,11 @@ class ShokzSupport : AbstractHeadphoneBTBRDeviceSupport(LOG, MAX_MTU) { return true } + override fun setContext(gbDevice: GBDevice, btAdapter: BluetoothAdapter, context: Context) { + super.setContext(gbDevice, btAdapter, context) + setupMultipointBroadcastReceiver() + } + override fun initializeDevice(builder: TransactionBuilder): TransactionBuilder { packetBuffer.clear() timeoutHandler.removeCallbacksAndMessages(null) @@ -63,6 +78,7 @@ class ShokzSupport : AbstractHeadphoneBTBRDeviceSupport(LOG, MAX_MTU) { override fun dispose() { synchronized(ConnectionMonitor) { timeoutHandler.removeCallbacksAndMessages(null) + LocalBroadcastManager.getInstance(context).unregisterReceiver(multipointBroadcastReceiver) super.dispose() } } @@ -536,6 +552,98 @@ class ShokzSupport : AbstractHeadphoneBTBRDeviceSupport(LOG, MAX_MTU) { ) } + ShokzCommand.MULTIPOINT_RET -> { + val status = buf.getInt() + val statusBool = status == 0x00010100 + LOG.info("Multipoint status={} (0x{})", statusBool, status.toHexString()) + broadcastMultipointStatus(statusBool) + } + + ShokzCommand.MULTIPOINT_ON_ACK -> { + val enabled: Boolean + if (buf.remaining() >= 4) { + val status = buf.getInt() + LOG.info("Multipoint on ACK status={}", status) + enabled = status == 0 + } else { + LOG.warn("Multipoint on ACK payload too short") + enabled = false + } + broadcastMultipointStatus(enabled) + } + + ShokzCommand.MULTIPOINT_OFF_ACK -> { + val disabled: Boolean + if (buf.remaining() >= 4) { + val status = buf.getInt() + LOG.info("Multipoint off ACK status={}", status) + disabled = status == 0 + } else { + LOG.warn("Multipoint off ACK payload too short") + disabled = false + } + broadcastMultipointStatus(!disabled) + } + + ShokzCommand.MULTIPOINT_DEVICES_RET -> { + buf.get() // 0 + val numDevices = buf.get().toInt() and 0xff + LOG.debug("Got {} multipoint devices", numDevices); + val devices = mutableListOf() + repeat(numDevices) { + val idx = buf.get().toInt() and 0xff + val macAddress = ByteArray(6) + buf.get(macAddress) + macAddress.reverse() + val connected = buf.get() + val nameLength = buf.get().toInt() and 0xff + val nameBytes = ByteArray(nameLength) + buf.get(nameBytes) + val name = String(nameBytes, Charsets.UTF_8) + LOG.debug("Device {}: {} - {} ({})", idx, macAddress.toHexString(), name, connected) + devices.add( + MultipointDevice( + macAddress.joinToString(separator = ":") { + String.format("%02X", it) + }, + name, + connected.toInt() == 1 + ) + ) + } + + broadcastMultipointList(devices) + } + + ShokzCommand.MULTIPOINT_CONNECT_ACK -> { + val status = buf.getInt() + LOG.info("Multipoint connect ack, status = 0x{}", status.toHexString()) + when (status) { + 0x00000400 -> { + GB.toast("Too many connected devices", Toast.LENGTH_LONG, GB.WARN) + } + } + } + + ShokzCommand.MULTIPOINT_DEVICE_CONNECTION_NOTIFY -> { + LOG.info("Got multipoint device connection, requesting list") + requestPairedDevices() + } + + ShokzCommand.MULTIPOINT_DISCONNECT_ACK -> { + LOG.info("Multipoint disconnect ack, status = 0x{}", buf.getInt().toHexString()) + } + + ShokzCommand.MULTIPOINT_PAIR_SECOND_FINISH -> { + LOG.info("Multipoint pair second finish") + broadcastMultipointPairing(false) + } + + ShokzCommand.MULTIPOINT_START_PAIR_ACK -> { + LOG.info("Multipoint start pair ack, status = 0x{}", buf.getInt().toHexString()) + broadcastMultipointPairing(true) + } + else -> LOG.warn("Unhandled command {}, args={}", command, args.toHexString()) } } @@ -581,7 +689,142 @@ class ShokzSupport : AbstractHeadphoneBTBRDeviceSupport(LOG, MAX_MTU) { builder.write(*encodeCommand(message.command, message.args)) builder.queue() - timeoutHandler.postDelayed({ onCommandTimeout() }, 2000L) + when (message.command) { + ShokzCommand.MULTIPOINT_PAIR_SECOND_FINISH, + ShokzCommand.MULTIPOINT_START_PAIR_REQ -> { + sendNextCommand() + } + + else -> { + timeoutHandler.postDelayed({ onCommandTimeout() }, 2000L) + } + } + } + + private fun setupMultipointBroadcastReceiver() { + val intentFilter = IntentFilter().apply { + addAction(MultipointPairingActivity.ACTION_MULTIPOINT_ENABLE) + addAction(MultipointPairingActivity.ACTION_MULTIPOINT_DISABLE) + addAction(MultipointPairingActivity.ACTION_MULTIPOINT_GET_DEVICES) + addAction(MultipointPairingActivity.ACTION_MULTIPOINT_GET_STATUS) + addAction(MultipointPairingActivity.ACTION_MULTIPOINT_CONNECT_DEVICE) + addAction(MultipointPairingActivity.ACTION_MULTIPOINT_DISCONNECT_DEVICE) + addAction(MultipointPairingActivity.ACTION_MULTIPOINT_START_PAIRING) + } + + LocalBroadcastManager.getInstance(context).registerReceiver( + multipointBroadcastReceiver, + intentFilter + ) + } + + private val multipointBroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val device = intent?.getParcelableExtra(GBDevice.EXTRA_DEVICE) + if (device?.address != gbDevice.address) { + return // not for this device + } + + when (intent.action) { + MultipointPairingActivity.ACTION_MULTIPOINT_ENABLE -> { + LOG.info("Enabling multipoint") + queueCommand(ShokzCommand.MULTIPOINT_ON) + } + + MultipointPairingActivity.ACTION_MULTIPOINT_DISABLE -> { + val macAddress = bluetoothAdapter.address + LOG.debug("Disabling multipoint from mac address {}", macAddress) + val macAddressBytes = macAddress.replace(":", "").hexToByteArray() + macAddressBytes.reverse() + queueCommand(ShokzCommand.MULTIPOINT_OFF, macAddressBytes) + } + + MultipointPairingActivity.ACTION_MULTIPOINT_GET_DEVICES -> requestPairedDevices() + MultipointPairingActivity.ACTION_MULTIPOINT_GET_STATUS -> requestMultipointStatus() + MultipointPairingActivity.ACTION_MULTIPOINT_CONNECT_DEVICE -> { + val deviceAddress = intent.getStringExtra(MultipointPairingActivity.EXTRA_DEVICE_ADDRESS) + if (deviceAddress != null) { + connectToDevice(deviceAddress) + } + } + + MultipointPairingActivity.ACTION_MULTIPOINT_DISCONNECT_DEVICE -> { + val deviceAddress = intent.getStringExtra(MultipointPairingActivity.EXTRA_DEVICE_ADDRESS) + if (deviceAddress != null) { + disconnectFromDevice(deviceAddress) + } + } + + MultipointPairingActivity.ACTION_MULTIPOINT_START_PAIRING -> { + val enabled = intent.getBooleanExtra(MultipointPairingActivity.EXTRA_PAIRING_ENABLED, false) + startPairingMode(enabled) + } + + else -> LOG.warn("Unknown action {}", intent.action) + } + } + } + + private fun requestPairedDevices() { + LOG.info("Requesting paired devices") + queueCommand(ShokzCommand.MULTIPOINT_DEVICES_GET) + } + + private fun requestMultipointStatus() { + LOG.info("Requesting multipoint status") + queueCommand(ShokzCommand.MULTIPOINT_GET) + } + + private fun connectToDevice(deviceAddress: String) { + LOG.info("Connecting to {}", deviceAddress) + + val macAddress = deviceAddress.replace(":", "").hexToByteArray() + macAddress.reverse() + queueCommand(ShokzCommand.MULTIPOINT_CONNECT_REQ, macAddress) + } + + private fun disconnectFromDevice(deviceAddress: String) { + LOG.info("Disconnecting from {}", deviceAddress) + + val macAddress = deviceAddress.replace(":", "").hexToByteArray() + macAddress.reverse() + queueCommand(ShokzCommand.MULTIPOINT_DISCONNECT_REQ, macAddress) + } + + private fun startPairingMode(enabled: Boolean) { + LOG.info("Starting pairing mode enabled={}", enabled) + if (enabled) { + queueCommand(ShokzCommand.MULTIPOINT_START_PAIR_REQ) + } else { + queueCommand(ShokzCommand.MULTIPOINT_PAIR_SECOND_FINISH) + } + } + + private fun broadcastMultipointStatus(enabled: Boolean) { + val intent = Intent(MultipointPairingActivity.ACTION_MULTIPOINT_STATUS_UPDATE).apply { + putExtra(GBDevice.EXTRA_DEVICE, device) + putExtra(MultipointPairingActivity.EXTRA_MULTIPOINT_ENABLED, enabled) + } + LocalBroadcastManager.getInstance(context).sendBroadcast(intent) + } + + private fun broadcastMultipointPairing(enabled: Boolean) { + val intent = Intent(MultipointPairingActivity.ACTION_MULTIPOINT_PAIRING_UPDATE).apply { + putExtra(GBDevice.EXTRA_DEVICE, device) + putExtra(MultipointPairingActivity.EXTRA_PAIRING_ENABLED, enabled) + } + LocalBroadcastManager.getInstance(context).sendBroadcast(intent) + } + + private fun broadcastMultipointList(devices: List) { + val intent = Intent(MultipointPairingActivity.ACTION_MULTIPOINT_DEVICE_LIST).apply { + putExtra(GBDevice.EXTRA_DEVICE, device) + putParcelableArrayListExtra( + MultipointPairingActivity.EXTRA_DEVICE_LIST, + ArrayList(devices) + ) + } + LocalBroadcastManager.getInstance(context).sendBroadcast(intent) } companion object { diff --git a/app/src/main/res/drawable/ic_bluetooth_disabled.xml b/app/src/main/res/drawable/ic_bluetooth_disabled.xml new file mode 100644 index 0000000000..c795ef4ad3 --- /dev/null +++ b/app/src/main/res/drawable/ic_bluetooth_disabled.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_multipoint_pairing.xml b/app/src/main/res/layout/activity_multipoint_pairing.xml new file mode 100644 index 0000000000..081ddd0f80 --- /dev/null +++ b/app/src/main/res/layout/activity_multipoint_pairing.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + +