diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index b2dbe47539..afc08cf0d3 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3247,6 +3247,11 @@ ${app_name} Push to Talk A service is running to communicate with BLE device + Walkie-Talkie Call + Configure push to talk device + Bluetooth + Connected + Disconnected %d message removed diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt index bb9a724794..fdf66b3cb0 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt @@ -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() } diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt index 0eecedd433..45c1c6588a 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt @@ -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() 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) diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt index a8304310f4..0e495aa82d 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt @@ -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() { diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewState.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewState.kt index cd2ed23980..f697fbebbd 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewState.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewState.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyDevice.kt b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyDevice.kt new file mode 100644 index 0000000000..5a8814fb02 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyDevice.kt @@ -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, +) diff --git a/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyDeviceItem.kt b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyDeviceItem.kt new file mode 100644 index 0000000000..7cccbb9527 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyDeviceItem.kt @@ -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(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(R.id.bluetoothDeviceNameTextView) + val bluetoothDeviceMacAddressTextView by bind(R.id.bluetoothDeviceMacAddressTextView) + val bluetoothDeviceConnectionStatusTextView by bind(R.id.bluetoothDeviceConnectionStatusTextView) + } +} diff --git a/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyDeviceScanner.kt b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyDeviceScanner.kt index 77771904b9..8debb35e23 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyDeviceScanner.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyDeviceScanner.kt @@ -48,6 +48,7 @@ class BluetoothLowEnergyDeviceScanner @Inject constructor( } fun startScanning() { + stopScanning() bluetoothManager ?.adapter ?.bondedDevices diff --git a/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyDevicesBottomSheetController.kt b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyDevicesBottomSheetController.kt new file mode 100644 index 0000000000..4e09dcdaa9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyDevicesBottomSheetController.kt @@ -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? = null + var callback: Callback? = null + + fun setData(deviceList: List) { + 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) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyServiceConnection.kt b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyServiceConnection.kt index d6d0d8da07..698474ae01 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyServiceConnection.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyServiceConnection.kt @@ -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 diff --git a/vector/src/main/res/layout/fragment_room_widget.xml b/vector/src/main/res/layout/fragment_room_widget.xml index abd85fff4d..268ca51157 100644 --- a/vector/src/main/res/layout/fragment_room_widget.xml +++ b/vector/src/main/res/layout/fragment_room_widget.xml @@ -1,53 +1,91 @@ - - + android:layout_height="match_parent"> - + - - - - - + android:layout_centerInParent="true" + android:indeterminate="true" /> + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/vector/src/main/res/layout/item_bluetooth_device.xml b/vector/src/main/res/layout/item_bluetooth_device.xml new file mode 100644 index 0000000000..24d4a31aa6 --- /dev/null +++ b/vector/src/main/res/layout/item_bluetooth_device.xml @@ -0,0 +1,40 @@ + + + + + + + + + + diff --git a/vector/src/main/res/menu/menu_widget.xml b/vector/src/main/res/menu/menu_widget.xml index d2dd6614c1..51f3e32476 100644 --- a/vector/src/main/res/menu/menu_widget.xml +++ b/vector/src/main/res/menu/menu_widget.xml @@ -27,4 +27,10 @@ android:title="@string/room_widget_revoke_access" app:showAsAction="never" /> - \ No newline at end of file + + +