Implement bluetooth device list bottom sheet.

This commit is contained in:
Onuray Sahin 2022-11-02 13:57:24 +03:00
parent 706f513baf
commit b3b5a5bfe6
13 changed files with 347 additions and 73 deletions

View File

@ -3247,6 +3247,11 @@
<!-- Element Call Widget - Push to Talk -->
<string name="push_to_talk_notification_title">${app_name} Push to Talk</string>
<string name="push_to_talk_notification_description">A service is running to communicate with BLE device</string>
<string name="push_to_talk_activity_title">Walkie-Talkie Call</string>
<string name="action_push_to_talk_configure_device">Configure push to talk device</string>
<string name="push_to_talk_bottom_sheet_title">Bluetooth</string>
<string name="push_to_talk_device_connected">Connected</string>
<string name="push_to_talk_device_disconnected">Disconnected</string>
<plurals name="room_removed_messages">
<item quantity="one">%d message removed</item>

View File

@ -16,7 +16,6 @@
package im.vector.app.features.widgets
import android.bluetooth.BluetoothDevice
import im.vector.app.core.platform.VectorViewModelAction
sealed class WidgetAction : VectorViewModelAction {
@ -27,7 +26,7 @@ sealed class WidgetAction : VectorViewModelAction {
object DeleteWidget : WidgetAction()
object RevokeWidget : WidgetAction()
object OnTermsReviewed : WidgetAction()
data class ConnectToBluetoothDevice(val device: BluetoothDevice) : WidgetAction()
data class ConnectToBluetoothDevice(val deviceAddress: String) : WidgetAction()
object HangupElementCall : WidgetAction()
object CloseWidget : WidgetAction()
}

View File

@ -35,7 +35,6 @@ import android.webkit.PermissionRequest
import android.webkit.WebMessage
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.view.isInvisible
@ -47,30 +46,34 @@ import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.args
import com.airbnb.mvrx.withState
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.platform.OnBackPressed
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.platform.VectorMenuProvider
import im.vector.app.core.utils.CheckWebViewPermissionsUseCase
import im.vector.app.core.utils.PERMISSIONS_FOR_BLUETOOTH
import im.vector.app.core.utils.checkPermissions
import im.vector.app.core.utils.onPermissionDeniedDialog
import im.vector.app.core.platform.VectorMenuProvider
import im.vector.app.core.utils.CheckWebViewPermissionsUseCase
import im.vector.app.core.utils.openUrlInExternalBrowser
import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.databinding.FragmentRoomWidgetBinding
import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.webview.WebEventListener
import im.vector.app.features.widgets.ptt.BluetoothLowEnergyDevice
import im.vector.app.features.widgets.ptt.BluetoothLowEnergyDeviceScanner
import im.vector.app.features.widgets.ptt.BluetoothLowEnergyDevicesBottomSheetController
import im.vector.app.features.widgets.ptt.BluetoothLowEnergyService
import im.vector.app.features.widgets.webview.WebviewPermissionUtils
import im.vector.app.features.widgets.webview.clearAfterWidget
import im.vector.app.features.widgets.webview.setupForWidget
import im.vector.lib.core.utils.compat.resolveActivityCompat
import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.terms.TermsService
import timber.log.Timber
import java.net.URISyntaxException
@ -96,6 +99,7 @@ class WidgetFragment :
@Inject lateinit var checkWebViewPermissionsUseCase: CheckWebViewPermissionsUseCase
@Inject lateinit var vectorPreferences: VectorPreferences
@Inject lateinit var bluetoothLowEnergyDeviceScanner: BluetoothLowEnergyDeviceScanner
@Inject lateinit var bluetoothLowEnergyDevicesBottomSheetController: BluetoothLowEnergyDevicesBottomSheetController
private val fragmentArgs: WidgetArgs by args()
private val viewModel: WidgetViewModel by activityViewModel()
@ -127,6 +131,12 @@ class WidgetFragment :
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
configureAudioDevice()
}
views.widgetBluetoothListRecyclerView.configureWith(bluetoothLowEnergyDevicesBottomSheetController, hasFixedSize = false)
bluetoothLowEnergyDevicesBottomSheetController.callback = object : BluetoothLowEnergyDevicesBottomSheetController.Callback {
override fun onItemSelected(deviceAddress: String) {
onBluetoothDeviceSelected(deviceAddress)
}
}
}
viewModel.observeViewEvents {
@ -175,6 +185,7 @@ class WidgetFragment :
viewModel.getPostAPIMediator().clearWebView()
}
views.widgetWebView.clearAfterWidget()
views.widgetBluetoothListRecyclerView.cleanup()
super.onDestroyView()
}
@ -201,7 +212,8 @@ class WidgetFragment :
override fun handlePrepareMenu(menu: Menu) {
withState(viewModel) { state ->
val widget = state.asyncWidget()
menu.findItem(R.id.action_edit)?.isVisible = state.widgetKind != WidgetKind.INTEGRATION_MANAGER
menu.findItem(R.id.action_edit)?.isVisible = state.widgetKind !in listOf(WidgetKind.INTEGRATION_MANAGER, WidgetKind.ELEMENT_CALL)
menu.findItem(R.id.action_push_to_talk)?.isVisible = state.widgetKind == WidgetKind.ELEMENT_CALL
if (widget == null) {
menu.findItem(R.id.action_refresh)?.isVisible = false
menu.findItem(R.id.action_widget_open_ext)?.isVisible = false
@ -251,6 +263,10 @@ class WidgetFragment :
}
true
}
R.id.action_push_to_talk -> {
showBluetoothLowEnergyDevicesBottomSheet()
true
}
else -> false
}
}
@ -413,15 +429,12 @@ class WidgetFragment :
viewModel.handle(WidgetAction.RevokeWidget)
}
private var deviceListDialog: AlertDialog? = null
private fun startBluetoothScanning() {
val deviceListDialogBuilder = MaterialAlertDialogBuilder(requireContext())
val bluetoothDevices = mutableListOf<BluetoothDevice>()
bluetoothLowEnergyDeviceScanner.callback = object : BluetoothLowEnergyDeviceScanner.Callback {
override fun onPairedDeviceFound(device: BluetoothDevice) {
onBluetoothDeviceSelected(device)
onBluetoothDeviceSelected(device.address)
}
override fun onScanResult(device: BluetoothDevice) {
@ -432,24 +445,28 @@ class WidgetFragment :
bluetoothDevices.add(device)
deviceListDialogBuilder.setItems(
bluetoothDevices.map { it.name + " " + it.address }.toTypedArray()
) { _, which ->
Timber.d("### WidgetFragment. $which selected")
onBluetoothDeviceSelected(bluetoothDevices[which])
}
if (deviceListDialog?.isShowing.orFalse()) {
deviceListDialog?.dismiss()
}
deviceListDialog = deviceListDialogBuilder.show()
bluetoothLowEnergyDevicesBottomSheetController.setData(
bluetoothDevices.map {
BluetoothLowEnergyDevice(
name = it.name,
macAddress = it.address,
isConnected = it.bondState == BluetoothDevice.BOND_BONDED
)
}
)
}
}
bluetoothLowEnergyDeviceScanner.startScanning()
}
private fun onBluetoothDeviceSelected(device: BluetoothDevice) {
viewModel.handle(WidgetAction.ConnectToBluetoothDevice(device))
private fun showBluetoothLowEnergyDevicesBottomSheet() {
bluetoothLowEnergyDeviceScanner.startScanning()
views.bottomSheet.isVisible = true
BottomSheetBehavior.from(views.bottomSheet).state = BottomSheetBehavior.STATE_HALF_EXPANDED
}
private fun onBluetoothDeviceSelected(deviceAddress: String) {
viewModel.handle(WidgetAction.ConnectToBluetoothDevice(deviceAddress))
Intent(requireContext(), BluetoothLowEnergyService::class.java).also {
ContextCompat.startForegroundService(requireContext(), it)

View File

@ -161,7 +161,7 @@ class WidgetViewModel @AssistedInject constructor(
}
private fun handleConnectToBluetoothDevice(action: WidgetAction.ConnectToBluetoothDevice) {
bluetoothLowEnergyServiceConnection.bind(action.device, this)
bluetoothLowEnergyServiceConnection.bind(action.deviceAddress, this)
}
private fun handleCloseWidget() {

View File

@ -34,7 +34,7 @@ enum class WidgetKind(@StringRes val nameRes: Int, val screenId: String?) {
ROOM(R.string.room_widget_activity_title, null),
STICKER_PICKER(R.string.title_activity_choose_sticker, WidgetType.StickerPicker.preferred),
INTEGRATION_MANAGER(0, null),
ELEMENT_CALL(0, null);
ELEMENT_CALL(R.string.push_to_talk_activity_title, null);
fun isAdmin(): Boolean {
return this == STICKER_PICKER || this == INTEGRATION_MANAGER

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* 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.
*/
package im.vector.app.features.widgets.ptt
data class BluetoothLowEnergyDevice(
val name: String,
val macAddress: String?,
val isConnected: Boolean,
)

View File

@ -0,0 +1,76 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* 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.
*/
package im.vector.app.features.widgets.ptt
import android.widget.TextView
import androidx.annotation.ColorInt
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.features.themes.ThemeUtils
@EpoxyModelClass
abstract class BluetoothLowEnergyDeviceItem : VectorEpoxyModel<BluetoothLowEnergyDeviceItem.Holder>(R.layout.item_bluetooth_device) {
interface Callback {
fun onItemSelected(deviceAddress: String)
}
@EpoxyAttribute
var deviceName: String? = null
@EpoxyAttribute
var deviceMacAddress: String? = null
@EpoxyAttribute
var deviceConnectionStatusText: String? = null
@EpoxyAttribute
@ColorInt
var deviceConnectionStatusTextColor: Int? = null
@EpoxyAttribute
var callback: Callback? = null
override fun bind(holder: Holder) {
super.bind(holder)
holder.bluetoothDeviceNameTextView.setTextOrHide(deviceName)
holder.bluetoothDeviceMacAddressTextView.setTextOrHide(deviceMacAddress)
holder.bluetoothDeviceConnectionStatusTextView.setTextOrHide(deviceConnectionStatusText)
deviceConnectionStatusTextColor?.let {
holder.bluetoothDeviceConnectionStatusTextView.setTextColor(it)
} ?: run {
holder.bluetoothDeviceConnectionStatusTextView.setTextColor(ThemeUtils.getColor(holder.view.context, R.attr.vctr_content_primary))
}
holder.view.setOnClickListener {
deviceMacAddress?.let {
callback?.onItemSelected(it)
}
}
}
class Holder : VectorEpoxyHolder() {
val bluetoothDeviceNameTextView by bind<TextView>(R.id.bluetoothDeviceNameTextView)
val bluetoothDeviceMacAddressTextView by bind<TextView>(R.id.bluetoothDeviceMacAddressTextView)
val bluetoothDeviceConnectionStatusTextView by bind<TextView>(R.id.bluetoothDeviceConnectionStatusTextView)
}
}

View File

@ -48,6 +48,7 @@ class BluetoothLowEnergyDeviceScanner @Inject constructor(
}
fun startScanning() {
stopScanning()
bluetoothManager
?.adapter
?.bondedDevices

View File

@ -0,0 +1,70 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* 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.
*/
package im.vector.app.features.widgets.ptt
import com.airbnb.epoxy.EpoxyController
import im.vector.app.R
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.StringProvider
import javax.inject.Inject
class BluetoothLowEnergyDevicesBottomSheetController @Inject constructor(
private val stringProvider: StringProvider,
private val colorProvider: ColorProvider,
) : EpoxyController() {
interface Callback {
fun onItemSelected(deviceAddress: String)
}
private var deviceList: List<BluetoothLowEnergyDevice>? = null
var callback: Callback? = null
fun setData(deviceList: List<BluetoothLowEnergyDevice>) {
this.deviceList = deviceList
requestModelBuild()
}
override fun buildModels() {
val currentDeviceList = deviceList ?: return
val host = this
currentDeviceList.forEach { device ->
val deviceConnectionStatus = host.stringProvider.getString(
if (device.isConnected) R.string.push_to_talk_device_connected else R.string.push_to_talk_device_disconnected
)
val deviceConnectionStatusColor = host.colorProvider.getColorFromAttribute(
if (device.isConnected) R.attr.colorPrimary else R.attr.colorError
)
val deviceItemCallback = object : BluetoothLowEnergyDeviceItem.Callback {
override fun onItemSelected(deviceAddress: String) {
host.callback?.onItemSelected(deviceAddress)
}
}
bluetoothLowEnergyDeviceItem {
id(device.hashCode())
deviceName(device.name)
deviceMacAddress(device.macAddress)
deviceConnectionStatusText(deviceConnectionStatus)
deviceConnectionStatusTextColor(deviceConnectionStatusColor)
callback(deviceItemCallback)
}
}
}
}

View File

@ -16,7 +16,6 @@
package im.vector.app.features.widgets.ptt
import android.bluetooth.BluetoothDevice
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@ -34,12 +33,12 @@ class BluetoothLowEnergyServiceConnection @Inject constructor(
private var isBound = false
private var bluetoothLowEnergyService: BluetoothLowEnergyService? = null
private var bluetoothDevice: BluetoothDevice? = null
private var deviceAddress: String? = null
var callback: Callback? = null
fun bind(device: BluetoothDevice, callback: Callback) {
this.bluetoothDevice = device
fun bind(deviceAddress: String, callback: Callback) {
this.deviceAddress = deviceAddress
this.callback = callback
if (!isBound) {
@ -54,7 +53,7 @@ class BluetoothLowEnergyServiceConnection @Inject constructor(
it.callback = this
}
bluetoothDevice?.address?.let {
deviceAddress?.let {
bluetoothLowEnergyService?.connect(it)
}
isBound = true

View File

@ -1,53 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/widgetWebView"
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignParentBottom="true"
android:layout_marginBottom="0dp"
android:background="@android:color/transparent" />
android:layout_height="match_parent">
<ProgressBar
android:id="@+id/widgetProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:indeterminate="true" />
<WebView
android:id="@+id/widgetWebView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignParentBottom="true"
android:layout_marginBottom="0dp"
android:background="@android:color/transparent" />
<LinearLayout
android:id="@+id/widgetErrorLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:background="?colorSurface"
android:orientation="horizontal"
android:padding="16dp"
android:visibility="gone"
tools:visibility="visible">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/error" />
<TextView
android:id="@+id/widgetErrorText"
style="@style/Widget.Vector.TextView.Subtitle"
<ProgressBar
android:id="@+id/widgetProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:textColor="?vctr_content_primary"
android:textStyle="bold"
tools:text="Fail to load widget " />
android:layout_centerInParent="true"
android:indeterminate="true" />
<LinearLayout
android:id="@+id/widgetErrorLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:background="?colorSurface"
android:orientation="horizontal"
android:padding="16dp"
android:visibility="gone"
tools:visibility="visible">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/error" />
<TextView
android:id="@+id/widgetErrorText"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:textColor="?vctr_content_primary"
android:textStyle="bold"
tools:text="Fail to load widget " />
</LinearLayout>
</RelativeLayout>
<LinearLayout
android:id="@+id/bottomSheet"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?vctr_system"
android:orientation="vertical"
android:visibility="gone"
app:behavior_hideable="true"
app:behavior_peekHeight="200dp"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
<TextView
style="@style/TextAppearance.Vector.Headline.Medium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginVertical="30dp"
android:paddingHorizontal="16dp"
android:text="@string/push_to_talk_bottom_sheet_title" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?vctr_list_separator" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/widgetBluetoothListRecyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:listitem="@layout/item_bluetooth_device" />
</LinearLayout>
</RelativeLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/bluetoothDeviceNameTextView"
style="@style/TextAppearance.Vector.Body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="30dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Device 1" />
<TextView
android:id="@+id/bluetoothDeviceMacAddressTextView"
style="@style/TextAppearance.Vector.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@id/bluetoothDeviceNameTextView"
app:layout_constraintTop_toBottomOf="@id/bluetoothDeviceNameTextView"
tools:text="00:1B:44:11:3A:B7" />
<TextView
android:id="@+id/bluetoothDeviceConnectionStatusTextView"
style="@style/TextAppearance.Vector.Body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
app:layout_constraintBottom_toBottomOf="@id/bluetoothDeviceMacAddressTextView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/bluetoothDeviceNameTextView"
tools:text="Disconnected"
tools:textColor="?colorError" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -27,4 +27,10 @@
android:title="@string/room_widget_revoke_access"
app:showAsAction="never" />
<item
android:id="@+id/action_push_to_talk"
android:title="@string/action_push_to_talk_configure_device"
android:icon="@drawable/quantum_ic_bluetooth_audio_grey600_24"
app:showAsAction="ifRoom" />
</menu>