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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_multipoint_device.xml b/app/src/main/res/layout/item_multipoint_device.xml
new file mode 100644
index 0000000000..166e6a49f1
--- /dev/null
+++ b/app/src/main/res/layout/item_multipoint_device.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 23298a19c3..67ca14f11a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -4240,4 +4240,11 @@
Long-press the multi-function button
Long-press volume up and down simultaneously
Track name cannot be empty
+ Multipoint pairing
+ Multipoint
+ Pair New Device
+ No paired devices found\n\nTap "Pair New Device" to add a device
+ Stop pairing
+ Paired Devices
+ Connect
diff --git a/app/src/main/res/xml/devicesettings_multipoint.xml b/app/src/main/res/xml/devicesettings_multipoint.xml
new file mode 100644
index 0000000000..9310fd8668
--- /dev/null
+++ b/app/src/main/res/xml/devicesettings_multipoint.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+