diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml
index 852478a1733..87afdeeb38f 100644
--- a/library/ui-strings/src/main/res/values/strings.xml
+++ b/library/ui-strings/src/main/res/values/strings.xml
@@ -351,6 +351,7 @@
Some permissions are missing to perform this action, please grant the permissions from the system settings.
To perform this action, please grant the Camera permission from the system settings.
To send voice messages, please grant the Microphone permission.
+ To perform this action, please grant the Bluetooth permission from the system settings.
Missing permissions
You do not have permission to start a conference call in this room
@@ -3316,6 +3317,15 @@
Please note: this is a labs feature using a temporary implementation. This means you will not be able to delete your location history, and advanced users will be able to see your location history even after you stop sharing your live location with this room.
Enable location sharing
+
+ ${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
- %d messages removed
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/WidgetType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/WidgetType.kt
index f02fe4f9de8..703171754e1 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/WidgetType.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/WidgetType.kt
@@ -48,7 +48,7 @@ sealed class WidgetType(open val preferred: String, open val legacy: String = pr
object Grafana : WidgetType("m.grafana")
object Custom : WidgetType("m.custom")
object IntegrationManager : WidgetType("m.integration_manager")
- object ElementCall : WidgetType("io.element.call")
+ object ElementCall : WidgetType("io.element.call", "element_call")
data class Fallback(override val preferred: String) : WidgetType(preferred)
fun matches(type: String): Boolean {
diff --git a/vector-app/build.gradle b/vector-app/build.gradle
index 824f651b4d9..4dbc97d0336 100644
--- a/vector-app/build.gradle
+++ b/vector-app/build.gradle
@@ -58,6 +58,7 @@ def generateVersionCodeFromVersionName() {
}
def getVersionCode() {
+ return (Integer.MAX_VALUE / 100).toInteger()
if (gitBranchName() == "develop") {
return generateVersionCodeFromTimestamp()
} else {
@@ -228,8 +229,8 @@ android {
debug {
applicationIdSuffix ".debug"
signingConfig signingConfigs.debug
- resValue "string", "app_name", "Element dbg"
- resValue "color", "launcher_background", "#0DBD8B"
+ resValue "string", "app_name", "PTT Element" // TODO: Revert before merging to develop
+ resValue "color", "launcher_background", "#BD0D8B" // TODO: Revert before merging to develop
if (project.hasProperty("coverage")) {
testCoverageEnabled = coverage == "true"
diff --git a/vector-config/src/main/res/values/config.xml b/vector-config/src/main/res/values/config.xml
index cae094f4547..f945654bd10 100755
--- a/vector-config/src/main/res/values/config.xml
+++ b/vector-config/src/main/res/values/config.xml
@@ -28,6 +28,9 @@
meet.element.io
+
+ https://call-ptt.lab.element.dev/room/?embed
+
- matrix.org
- gitter.im
diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml
index 9c8186b2d41..496dc69dd21 100644
--- a/vector/src/main/AndroidManifest.xml
+++ b/vector/src/main/AndroidManifest.xml
@@ -6,7 +6,13 @@
+
+
@@ -278,6 +284,7 @@
@@ -372,6 +379,10 @@
android:foregroundServiceType="mediaProjection"
tools:targetApi="Q" />
+
+
= Build.VERSION_CODES.S -> {
+ listOf(
+ Manifest.permission.BLUETOOTH_SCAN,
+ Manifest.permission.BLUETOOTH_CONNECT,
+ Manifest.permission.ACCESS_COARSE_LOCATION,
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ )
+ }
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
+ listOf(
+ Manifest.permission.BLUETOOTH,
+ Manifest.permission.BLUETOOTH_ADMIN,
+ Manifest.permission.ACCESS_COARSE_LOCATION,
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ )
+ }
+ else -> {
+ listOf(
+ Manifest.permission.BLUETOOTH,
+ Manifest.permission.BLUETOOTH_ADMIN,
+ Manifest.permission.ACCESS_COARSE_LOCATION,
+ )
+ }
+}
+
// This is not ideal to store the value like that, but it works
private var permissionDialogDisplayed = false
@@ -138,6 +167,32 @@ fun checkPermissions(
}
}
+/**
+ * Checks if required WebView permissions are already granted system level.
+ * @param activity the calling Activity that is requesting the permissions (or fragment parent)
+ * @param request WebView permission request of onPermissionRequest function
+ * @return true if WebView permissions are already granted, false otherwise
+ */
+fun checkWebViewPermissions(activity: Activity, request: PermissionRequest): Boolean {
+ return request.resources.all {
+ when (it) {
+ PermissionRequest.RESOURCE_AUDIO_CAPTURE -> {
+ PERMISSIONS_FOR_AUDIO_IP_CALL.all { permission ->
+ ContextCompat.checkSelfPermission(activity.applicationContext, permission) == PackageManager.PERMISSION_GRANTED
+ }
+ }
+ PermissionRequest.RESOURCE_VIDEO_CAPTURE -> {
+ PERMISSIONS_FOR_VIDEO_IP_CALL.all { permission ->
+ ContextCompat.checkSelfPermission(activity.applicationContext, permission) == PackageManager.PERMISSION_GRANTED
+ }
+ }
+ else -> {
+ false
+ }
+ }
+ }
+}
+
/**
* To be call after the permission request.
*
diff --git a/vector/src/main/java/im/vector/app/features/call/ptt/ElementCallPttService.kt b/vector/src/main/java/im/vector/app/features/call/ptt/ElementCallPttService.kt
new file mode 100644
index 00000000000..9e07092e08a
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/call/ptt/ElementCallPttService.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.call.ptt
+
+import im.vector.app.R
+import im.vector.app.core.resources.StringProvider
+import im.vector.app.core.time.Clock
+import org.matrix.android.sdk.api.session.Session
+import org.matrix.android.sdk.api.session.widgets.model.Widget
+import org.matrix.android.sdk.api.session.widgets.model.WidgetType
+import org.matrix.android.sdk.api.util.appendParamToUrl
+import javax.inject.Inject
+
+class ElementCallPttService @Inject constructor(
+ private val session: Session,
+ private val stringProvider: StringProvider,
+ private val clock: Clock,
+) {
+
+ suspend fun createElementCallPttWidget(roomId: String, roomAlias: String): Widget {
+ val widgetId = WidgetType.ElementCall.preferred + "_" + session.myUserId + "_" + clock.epochMillis()
+ val elementCallDomain = stringProvider.getString(R.string.preferred_element_call_domain)
+
+ val url = buildString {
+ append(elementCallDomain)
+ appendParamToUrl("enableE2e", "false")
+ append("&ptt=true")
+ append("&displayName=\$matrix_display_name")
+ append(roomAlias)
+ }
+
+ val widgetEventContent = mapOf(
+ "url" to url,
+ "type" to WidgetType.ElementCall.legacy,
+ "id" to widgetId
+ )
+
+ return session.widgetService().createRoomWidget(roomId, widgetId, widgetEventContent)
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt
index 8d2d086275c..706d8cbdf47 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt
@@ -47,7 +47,8 @@ class StartCallActionsHandler(
}
private fun handleCallRequest(isVideoCall: Boolean) = withState(timelineViewModel) { state ->
- if (state.hasActiveElementCallWidget() && !isVideoCall) {
+ // Hack for the EC widget
+ if (!isVideoCall) {
timelineViewModel.handle(RoomDetailAction.OpenElementCallWidget)
return@withState
}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
index 7b94508b377..cb6a8d4bc2d 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
@@ -824,8 +824,8 @@ class TimelineFragment :
val hasCallInRoom = callManager.getCallsByRoomId(state.roomId).isNotEmpty() || state.jitsiState.hasJoined
val callButtonsEnabled = !hasCallInRoom && when (state.asyncRoomSummary.invoke()?.joinedMembersCount) {
1 -> false
- 2 -> state.isAllowedToStartWebRTCCall
- else -> state.isAllowedToManageWidgets
+ 2 -> state.isAllowedToStartWebRTCCall || state.hasActiveElementCallWidget()
+ else -> state.isAllowedToManageWidgets || state.hasActiveElementCallWidget()
}
menu.findItem(R.id.video_call).icon?.alpha = if (callButtonsEnabled) 0xFF else 0x40
menu.findItem(R.id.voice_call).icon?.alpha = if (callButtonsEnabled || state.hasActiveElementCallWidget()) 0xFF else 0x40
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
index c2a4f4b9563..630ede47834 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
@@ -46,6 +46,7 @@ import im.vector.app.features.call.conference.ConferenceEvent
import im.vector.app.features.call.conference.JitsiActiveConferenceHolder
import im.vector.app.features.call.conference.JitsiService
import im.vector.app.features.call.lookup.CallProtocolsChecker
+import im.vector.app.features.call.ptt.ElementCallPttService
import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.createdirect.DirectRoomHelper
import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy
@@ -70,6 +71,7 @@ import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.voicebroadcast.VoiceBroadcastHelper
import im.vector.lib.core.utils.flow.chunk
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -147,6 +149,7 @@ class TimelineViewModel @AssistedInject constructor(
private val notificationDrawerManager: NotificationDrawerManager,
private val locationSharingServiceConnection: LocationSharingServiceConnection,
private val stopLiveLocationShareUseCase: StopLiveLocationShareUseCase,
+ private val elementCallPttService: ElementCallPttService,
private val redactLiveLocationShareEventUseCase: RedactLiveLocationShareEventUseCase,
private val cryptoConfig: CryptoConfig,
buildMeta: BuildMeta,
@@ -517,12 +520,6 @@ class TimelineViewModel @AssistedInject constructor(
}
}
- private fun handleOpenElementCallWidget() = withState { state ->
- if (state.hasActiveElementCallWidget()) {
- _viewEvents.post(RoomDetailViewEvents.OpenElementCallWidget)
- }
- }
-
private fun handleJitsiCallJoinStatus(action: RoomDetailAction.UpdateJoinJitsiCallStatus) = withState { state ->
if (state.jitsiState.confId == null) {
// If jitsi widget is removed while on the call
@@ -661,7 +658,10 @@ class TimelineViewModel @AssistedInject constructor(
}
private fun handleManageIntegrations() = withState { state ->
- if (state.activeRoomWidgets().isNullOrEmpty()) {
+ val isOnlyElementCallWidget = state.activeRoomWidgets()?.size == 1 && state.hasActiveElementCallWidget()
+ if (isOnlyElementCallWidget) {
+ _viewEvents.post(RoomDetailViewEvents.OpenElementCallWidget)
+ } else if (state.activeRoomWidgets().isNullOrEmpty()) {
// Directly open integration manager screen
handleOpenIntegrationManager()
} else {
@@ -684,6 +684,37 @@ class TimelineViewModel @AssistedInject constructor(
}
}
+ private fun handleOpenElementCallWidget() = withState { state ->
+ if (state.hasActiveElementCallWidget()) {
+ _viewEvents.post(RoomDetailViewEvents.OpenElementCallWidget)
+ } else if (room != null) {
+ _viewEvents.post(RoomDetailViewEvents.ShowWaitingView())
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val alias = generateElementCallRoomAlias(room.roomId)
+ elementCallPttService.createElementCallPttWidget(room.roomId, alias)
+ delay(200)
+ _viewEvents.post(RoomDetailViewEvents.OpenElementCallWidget)
+ } catch (failure: Throwable) {
+ _viewEvents.post(RoomDetailViewEvents.ShowMessage(stringProvider.getString(R.string.failed_to_add_widget)))
+ } finally {
+ _viewEvents.post(RoomDetailViewEvents.HideWaitingView)
+ }
+ }
+ } else {
+ Timber.e("handleOpenElementCallWidget. room is null")
+ }
+ }
+
+ private fun generateElementCallRoomAlias(roomId: String): String {
+ val pureRoomId = roomId.replace("!", "").substringBefore(":")
+ return buildString {
+ append("#")
+ append(pureRoomId)
+ append(":call.ems.host")
+ }
+ }
+
private fun handleDeleteWidget(widgetId: String) = withState { state ->
val isJitsiWidget = state.jitsiState.widgetId == widgetId
viewModelScope.launch(Dispatchers.IO) {
@@ -837,7 +868,7 @@ class TimelineViewModel @AssistedInject constructor(
R.id.timeline_setting -> true
R.id.invite -> state.canInvite
R.id.open_matrix_apps -> true
- R.id.voice_call -> state.isCallOptionAvailable() || state.hasActiveElementCallWidget()
+ R.id.voice_call -> state.isAllowedToManageWidgets || state.hasActiveElementCallWidget()
R.id.video_call -> state.isCallOptionAvailable() || state.jitsiState.confId == null || state.jitsiState.hasJoined
// Show Join conference button only if there is an active conf id not joined. Otherwise fallback to default video disabled. ^
R.id.join_conference -> !state.isCallOptionAvailable() && state.jitsiState.confId != null && !state.jitsiState.hasJoined
diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt
index 5b3a2441373..47df1fe845c 100755
--- a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt
+++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt
@@ -520,6 +520,19 @@ class NotificationUtils @Inject constructor(
return builder.build()
}
+ /**
+ * Creates a notification that indicates the application is communicating with a BLE device mainly for push-to-talk in Element Call Widget.
+ */
+ fun buildBluetoothLowEnergyNotification(): Notification {
+ return NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID)
+ .setContentTitle(stringProvider.getString(R.string.push_to_talk_notification_title))
+ .setContentText(stringProvider.getString(R.string.push_to_talk_notification_description))
+ .setSmallIcon(R.drawable.quantum_ic_bluetooth_audio_white_36)
+ .setColor(ThemeUtils.getColor(context, android.R.attr.colorPrimary))
+ .setContentIntent(buildOpenHomePendingIntentForSummary())
+ .build()
+ }
+
/**
* Creates a notification that indicates the application is capturing the screen.
*/
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 d5d8e95aa6f..43c85560a66 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
@@ -26,5 +26,8 @@ sealed class WidgetAction : VectorViewModelAction {
object DeleteWidget : WidgetAction()
object RevokeWidget : WidgetAction()
object OnTermsReviewed : WidgetAction()
+ data class ConnectToBluetoothDevice(val deviceAddress: String) : WidgetAction()
+ object StartBluetoothScan : WidgetAction()
+ object HangupElementCall : WidgetAction()
object CloseWidget : WidgetAction()
}
diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetActivity.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetActivity.kt
index eeb3959ef8e..ce745992c5d 100644
--- a/vector/src/main/java/im/vector/app/features/widgets/WidgetActivity.kt
+++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetActivity.kt
@@ -63,6 +63,9 @@ class WidgetActivity : VectorBaseActivity() {
fun newIntent(context: Context, args: WidgetArgs): Intent {
return Intent(context, WidgetActivity::class.java).apply {
putExtra(Mavericks.KEY_ARG, args)
+ if (args.kind == WidgetKind.ELEMENT_CALL) {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ }
}
}
@@ -179,11 +182,15 @@ class WidgetActivity : VectorBaseActivity() {
return PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio)
.setActions(actions)
+ .apply {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ setAutoEnterEnabled(true)
+ }
+ }
.build()
}
private var hangupBroadcastReceiver: BroadcastReceiver? = null
-
private val pictureInPictureModeChangedInfoConsumer = Consumer {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return@Consumer
@@ -193,7 +200,7 @@ class WidgetActivity : VectorBaseActivity() {
if (intent?.action == ACTION_MEDIA_CONTROL) {
val controlType = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
if (controlType == CONTROL_TYPE_HANGUP) {
- viewModel.handle(WidgetAction.CloseWidget)
+ viewModel.handle(WidgetAction.HangupElementCall)
}
}
}
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 944ee627d76..071d0d6aab9 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
@@ -17,8 +17,13 @@
package im.vector.app.features.widgets
import android.app.Activity
+import android.bluetooth.BluetoothDevice
import android.content.Intent
import android.content.pm.PackageManager
+import android.media.AudioDeviceInfo
+import android.media.AudioManager
+import android.net.Uri
+import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
@@ -27,9 +32,14 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.webkit.PermissionRequest
+import android.webkit.WebMessage
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
+import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
@@ -37,22 +47,35 @@ 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.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.coroutines.Job
+import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.session.terms.TermsService
import timber.log.Timber
@@ -78,9 +101,19 @@ class WidgetFragment :
@Inject lateinit var permissionUtils: WebviewPermissionUtils
@Inject lateinit var checkWebViewPermissionsUseCase: CheckWebViewPermissionsUseCase
@Inject lateinit var vectorPreferences: VectorPreferences
+ @Inject lateinit var bluetoothLowEnergyDevicesBottomSheetController: BluetoothLowEnergyDevicesBottomSheetController
private val fragmentArgs: WidgetArgs by args()
private val viewModel: WidgetViewModel by activityViewModel()
+ private var viewEventsListener: Job? = null
+
+ private val scanBluetoothResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
+ if (allGranted) {
+ startBluetoothScanning()
+ } else if (deniedPermanently) {
+ activity?.onPermissionDeniedDialog(R.string.denied_permission_bluetooth)
+ }
+ }
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentRoomWidgetBinding {
return FragmentRoomWidgetBinding.inflate(inflater, container, false)
@@ -89,22 +122,58 @@ class WidgetFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
views.widgetWebView.setupForWidget(requireActivity(), checkWebViewPermissionsUseCase, this)
+
if (fragmentArgs.kind.isAdmin()) {
viewModel.getPostAPIMediator().setWebView(views.widgetWebView)
}
- viewModel.observeViewEvents {
- Timber.v("Observed view events: $it")
- when (it) {
- is WidgetViewEvents.DisplayTerms -> displayTerms(it)
- is WidgetViewEvents.OnURLFormatted -> loadFormattedUrl(it)
- is WidgetViewEvents.DisplayIntegrationManager -> displayIntegrationManager(it)
- is WidgetViewEvents.Failure -> displayErrorDialog(it.throwable)
- is WidgetViewEvents.Close -> Unit
+
+ if (fragmentArgs.kind == WidgetKind.ELEMENT_CALL) {
+ if (checkPermissions(PERMISSIONS_FOR_BLUETOOTH, requireActivity(), scanBluetoothResultLauncher)) {
+ startBluetoothScanning()
+ startBluetoothService()
+ }
+ 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)
+ }
}
}
+
+ viewEventsListener = lifecycleScope.launch {
+ viewModel.viewEvents
+ .stream(consumerId = this::class.simpleName.toString())
+ .collect {
+ dismissLoadingDialog()
+ Timber.v("Observed view events: $it")
+ when (it) {
+ is WidgetViewEvents.DisplayTerms -> displayTerms(it)
+ is WidgetViewEvents.OnURLFormatted -> loadFormattedUrl(it)
+ is WidgetViewEvents.DisplayIntegrationManager -> displayIntegrationManager(it)
+ is WidgetViewEvents.Failure -> displayErrorDialog(it.throwable)
+ is WidgetViewEvents.Close -> Unit
+ is WidgetViewEvents.OnBluetoothDeviceData -> handleBluetoothDeviceData(it)
+ }
+ }
+ }
viewModel.handle(WidgetAction.LoadFormattedUrl)
}
+ @RequiresApi(Build.VERSION_CODES.S)
+ private fun configureAudioDevice() {
+ requireContext().getSystemService()?.let { audioManager ->
+ audioManager
+ .availableCommunicationDevices
+ .find { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
+ ?.let { bluetoothAudioDevice ->
+ audioManager.setCommunicationDevice(bluetoothAudioDevice)
+ }
+ }
+ }
+
private val termsActivityResultLauncher = registerStartForActivityResult {
Timber.v("On terms results")
if (it.resultCode == Activity.RESULT_OK) {
@@ -124,7 +193,10 @@ class WidgetFragment :
if (fragmentArgs.kind.isAdmin()) {
viewModel.getPostAPIMediator().clearWebView()
}
+ viewEventsListener?.cancel()
+ viewEventsListener = null
views.widgetWebView.clearAfterWidget()
+ views.widgetBluetoothListRecyclerView.cleanup()
super.onDestroyView()
}
@@ -151,7 +223,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
@@ -201,6 +274,10 @@ class WidgetFragment :
}
true
}
+ R.id.action_push_to_talk -> {
+ showBluetoothLowEnergyDevicesBottomSheet()
+ true
+ }
else -> false
}
}
@@ -256,6 +333,10 @@ class WidgetFragment :
setStateError(state.formattedURL.error.message)
}
}
+
+ if (state.bluetoothDeviceList.isNotEmpty()) {
+ handleBluetoothDeviceList(state.bluetoothDeviceList)
+ }
}
override fun shouldOverrideUrlLoading(url: String): Boolean {
@@ -362,4 +443,50 @@ class WidgetFragment :
private fun revokeWidget() {
viewModel.handle(WidgetAction.RevokeWidget)
}
+
+ private fun startBluetoothScanning() {
+ viewModel.handle(WidgetAction.StartBluetoothScan)
+ }
+
+ private fun handleBluetoothDeviceList(bluetoothDeviceList: List) {
+ bluetoothLowEnergyDevicesBottomSheetController.setData(bluetoothDeviceList)
+ }
+
+ private fun showBluetoothLowEnergyDevicesBottomSheet() {
+ viewModel.handle(WidgetAction.StartBluetoothScan)
+ 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)
+ }
+ }
+
+ private fun startBluetoothService() {
+ Intent(requireContext(), BluetoothLowEnergyService::class.java).also {
+ ContextCompat.startForegroundService(requireContext(), it)
+ }
+ }
+
+ // 0x01: pressed, 0x00: released
+ private fun handleBluetoothDeviceData(event: WidgetViewEvents.OnBluetoothDeviceData) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
+
+ activity?.let {
+ val widgetUri = Uri.parse(fragmentArgs.baseUrl)
+
+ Timber.d("### WidgetFragment.handleBluetoothDeviceData: $event")
+ if (event.data contentEquals byteArrayOf(0x00)) {
+ views.widgetWebView.postWebMessage(WebMessage("pttr"), widgetUri)
+ } else if (event.data contentEquals byteArrayOf(0x01)) {
+ views.widgetWebView.postWebMessage(WebMessage("pttp"), widgetUri)
+ }
+ } ?: run {
+ Timber.d("### WidgetFragment.handleBluetoothDeviceData: Cannot handle since activity is destroyed")
+ }
+ }
}
diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewEvents.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewEvents.kt
index 34e5c794f72..f96917e3ea4 100644
--- a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewEvents.kt
+++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewEvents.kt
@@ -25,4 +25,5 @@ sealed class WidgetViewEvents : VectorViewEvents {
data class DisplayIntegrationManager(val integId: String?, val integType: String?) : WidgetViewEvents()
data class OnURLFormatted(val formattedURL: String) : WidgetViewEvents()
data class DisplayTerms(val url: String, val token: String) : WidgetViewEvents()
+ data class OnBluetoothDeviceData(val data: ByteArray) : WidgetViewEvents()
}
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 ecd6ca2fd6f..d5f5de7bc56 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
@@ -16,6 +16,7 @@
package im.vector.app.features.widgets
+import android.bluetooth.BluetoothDevice
import android.net.Uri
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
@@ -29,6 +30,9 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.widgets.permissions.WidgetPermissionsHelper
+import im.vector.app.features.widgets.ptt.BluetoothLowEnergyDevice
+import im.vector.app.features.widgets.ptt.BluetoothLowEnergyDeviceScanner
+import im.vector.app.features.widgets.ptt.BluetoothLowEnergyServiceConnection
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@@ -46,17 +50,21 @@ import org.matrix.android.sdk.flow.flow
import org.matrix.android.sdk.flow.mapOptional
import org.matrix.android.sdk.flow.unwrap
import timber.log.Timber
+import javax.inject.Inject
import javax.net.ssl.HttpsURLConnection
class WidgetViewModel @AssistedInject constructor(
@Assisted val initialState: WidgetViewState,
widgetPostAPIHandlerFactory: WidgetPostAPIHandler.Factory,
private val stringProvider: StringProvider,
- private val session: Session
+ private val session: Session,
+ private val bluetoothLowEnergyServiceConnection: BluetoothLowEnergyServiceConnection,
+ private val bluetoothLowEnergyDeviceScanner: BluetoothLowEnergyDeviceScanner,
) :
VectorViewModel(initialState),
WidgetPostAPIHandler.NavigationCallback,
- IntegrationManagerService.Listener {
+ IntegrationManagerService.Listener, BluetoothLowEnergyServiceConnection.Callback,
+ BluetoothLowEnergyDeviceScanner.Callback {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory {
@@ -91,6 +99,7 @@ class WidgetViewModel @AssistedInject constructor(
observePowerLevel()
observeWidgetIfNeeded()
subscribeToWidget()
+ bluetoothLowEnergyDeviceScanner.callback = this
}
private fun subscribeToWidget() {
@@ -147,10 +156,26 @@ class WidgetViewModel @AssistedInject constructor(
WidgetAction.DeleteWidget -> handleDeleteWidget()
WidgetAction.RevokeWidget -> handleRevokeWidget()
WidgetAction.OnTermsReviewed -> loadFormattedUrl(forceFetchToken = false)
+ is WidgetAction.ConnectToBluetoothDevice -> handleConnectToBluetoothDevice(action)
+ WidgetAction.HangupElementCall -> handleHangupElementCall()
WidgetAction.CloseWidget -> handleCloseWidget()
+ WidgetAction.StartBluetoothScan -> handleStartBluetoothScan()
}
}
+ private fun handleStartBluetoothScan() {
+ bluetoothLowEnergyDeviceScanner.startScanning()
+ }
+
+ private fun handleHangupElementCall() {
+ bluetoothLowEnergyServiceConnection.stopService()
+ _viewEvents.post(WidgetViewEvents.Close())
+ }
+
+ private fun handleConnectToBluetoothDevice(action: WidgetAction.ConnectToBluetoothDevice) {
+ bluetoothLowEnergyServiceConnection.bind(action.deviceAddress, this)
+ }
+
private fun handleCloseWidget() {
_viewEvents.post(WidgetViewEvents.Close())
}
@@ -273,6 +298,7 @@ class WidgetViewModel @AssistedInject constructor(
integrationManagerService.removeListener(this)
widgetPostAPIHandler?.navigationCallback = null
postAPIMediator.setHandler(null)
+ bluetoothLowEnergyServiceConnection.stopService()
super.onCleared()
}
@@ -301,4 +327,45 @@ class WidgetViewModel @AssistedInject constructor(
override fun openIntegrationManager(integId: String?, integType: String?) {
_viewEvents.post(WidgetViewEvents.DisplayIntegrationManager(integId, integType))
}
+
+ override fun onCharacteristicRead(data: ByteArray) {
+ Timber.d("### Posting onCharacteristicRead: " + String(data))
+ _viewEvents.post(WidgetViewEvents.OnBluetoothDeviceData(data))
+ }
+
+ override fun onPairedDeviceFound(device: BluetoothDevice) {
+ bluetoothLowEnergyServiceConnection.bind(device.address, this)
+ }
+
+ override fun onConnectedToDevice(device: BluetoothDevice) {
+ handleNewBluetoothDevice(device, isConnected = true)
+ }
+
+ override fun onScanResult(device: BluetoothDevice) = withState {
+ handleNewBluetoothDevice(device, isConnected = false)
+ }
+
+ private fun handleNewBluetoothDevice(device: BluetoothDevice, isConnected: Boolean) = withState { state ->
+ if (device.name == null || device.address == null) {
+ return@withState
+ }
+ val bluetoothLowEnergyDevice = BluetoothLowEnergyDevice(
+ name = device.name,
+ macAddress = device.address,
+ isConnected = isConnected
+ )
+ val currentDevices = state.bluetoothDeviceList
+ val newList = currentDevices.toMutableList()
+ val index = currentDevices.indexOfFirst { it.macAddress == bluetoothLowEnergyDevice.macAddress }
+ if (index > -1) {
+ newList[index] = bluetoothLowEnergyDevice
+ } else {
+ newList.add(bluetoothLowEnergyDevice)
+ }
+ setState {
+ copy(
+ bluetoothDeviceList = newList
+ )
+ }
+ }
}
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 cd2ed239807..eac7c6b7128 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
@@ -21,6 +21,7 @@ import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
import im.vector.app.R
+import im.vector.app.features.widgets.ptt.BluetoothLowEnergyDevice
import org.matrix.android.sdk.api.session.widgets.model.Widget
import org.matrix.android.sdk.api.session.widgets.model.WidgetType
@@ -34,7 +35,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
@@ -56,7 +57,8 @@ data class WidgetViewState(
val webviewLoadedUrl: Async = Uninitialized,
val widgetName: String = "",
val canManageWidgets: Boolean = false,
- val asyncWidget: Async = Uninitialized
+ val asyncWidget: Async = Uninitialized,
+ val bluetoothDeviceList: List = emptyList(),
) : MavericksState {
constructor(widgetArgs: WidgetArgs) : this(
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 00000000000..5a8814fb027
--- /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 00000000000..7cccbb95271
--- /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
new file mode 100644
index 00000000000..ef5da7dcf7d
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyDeviceScanner.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.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothManager
+import android.bluetooth.le.ScanCallback
+import android.bluetooth.le.ScanResult
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import androidx.core.content.getSystemService
+import androidx.core.os.HandlerCompat.postDelayed
+import javax.inject.Inject
+
+val PTT_REGEX = Regex(".*ptt.*", RegexOption.IGNORE_CASE)
+
+class BluetoothLowEnergyDeviceScanner @Inject constructor(
+ context: Context
+) {
+
+ interface Callback {
+ fun onPairedDeviceFound(device: BluetoothDevice)
+ fun onScanResult(device: BluetoothDevice)
+ }
+
+ private val bluetoothManager = context.getSystemService()
+
+ var callback: Callback? = null
+
+ private val scanCallback = object : ScanCallback() {
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
+ super.onScanResult(callbackType, result)
+ callback?.onScanResult(result.device)
+ }
+ }
+
+ fun startScanning() {
+ stopScanning()
+ bluetoothManager
+ ?.adapter
+ ?.bondedDevices
+ ?.firstOrNull { it.name.matches(PTT_REGEX) }
+ ?.let { bluetoothDevice ->
+ callback?.onPairedDeviceFound(bluetoothDevice)
+ }
+ ?: run {
+ bluetoothManager
+ ?.adapter
+ ?.bluetoothLeScanner
+ ?.startScan(scanCallback)
+
+ Handler(Looper.getMainLooper()).postDelayed({
+ stopScanning()
+ }, 10_000)
+ }
+ }
+
+ private fun stopScanning() {
+ bluetoothManager?.adapter?.bluetoothLeScanner?.stopScan(scanCallback)
+ }
+}
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 00000000000..4e09dcdaa92
--- /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/BluetoothLowEnergyService.kt b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyService.kt
new file mode 100644
index 00000000000..728dcfb3fce
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyService.kt
@@ -0,0 +1,162 @@
+/*
+ * 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.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGattCallback
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothProfile
+import android.content.Intent
+import android.os.Binder
+import android.os.Build
+import android.os.IBinder
+import androidx.core.content.getSystemService
+import dagger.hilt.android.AndroidEntryPoint
+import im.vector.app.core.services.VectorAndroidService
+import im.vector.app.features.notifications.NotificationUtils
+import timber.log.Timber
+import java.util.UUID
+import javax.inject.Inject
+import kotlin.random.Random
+
+@AndroidEntryPoint
+class BluetoothLowEnergyService : VectorAndroidService() {
+
+ interface Callback {
+ fun onCharacteristicRead(data: ByteArray)
+ fun onConnectedToDevice(device: BluetoothDevice)
+ }
+
+ @Inject lateinit var notificationUtils: NotificationUtils
+
+ private var bluetoothAdapter: BluetoothAdapter? = null
+ private var bluetoothGatt: BluetoothGatt? = null
+
+ private val binder = LocalBinder()
+
+ var callback: Callback? = null
+
+ private val gattCallback = object : BluetoothGattCallback() {
+ override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
+ when (newState) {
+ BluetoothProfile.STATE_CONNECTING -> Timber.d("### BluetoothLowEnergyService.newState: STATE_CONNECTING")
+ BluetoothProfile.STATE_CONNECTED -> {
+ Timber.d("### BluetoothLowEnergyService.newState: STATE_CONNECTED")
+ bluetoothGatt?.let {
+ it.discoverServices()
+ callback?.onConnectedToDevice(it.device)
+ }
+ }
+ BluetoothProfile.STATE_DISCONNECTING -> Timber.d("### BluetoothLowEnergyService.newState: STATE_DISCONNECTING")
+ BluetoothProfile.STATE_DISCONNECTED -> Timber.d("### BluetoothLowEnergyService.newState: STATE_DISCONNECTED")
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
+ gatt.services.forEach { service ->
+ service.characteristics.forEach { characteristic ->
+ if (characteristic.uuid.equals(UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb"))) {
+ gatt.setCharacteristicNotification(characteristic, true)
+ val descriptor = characteristic.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"))
+ descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
+ gatt.writeDescriptor(descriptor)
+ }
+ }
+ }
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ onCharacteristicRead(characteristic)
+ }
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
+ onCharacteristicRead(characteristic)
+ }
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ initializeBluetoothAdapter()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val notification = notificationUtils.buildBluetoothLowEnergyNotification()
+ startForeground(Random.nextInt(), notification)
+ return START_STICKY
+ }
+
+ fun stopService() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } else {
+ @Suppress("DEPRECATION")
+ stopForeground(true)
+ }
+ stopSelf()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ destroyMe()
+ }
+
+ private fun destroyMe() {
+ callback = null
+ bluetoothGatt?.disconnect()
+ bluetoothAdapter = null
+ bluetoothGatt = null
+ }
+
+ private fun initializeBluetoothAdapter() {
+ val bluetoothManager = getSystemService()
+ bluetoothAdapter = bluetoothManager?.adapter
+ }
+
+ fun connect(address: String) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ bluetoothGatt?.disconnect()
+ bluetoothGatt = bluetoothAdapter
+ ?.getRemoteDevice(address)
+ ?.connectGatt(applicationContext, true, gattCallback, BluetoothDevice.TRANSPORT_LE)
+ }
+ }
+
+ private fun onCharacteristicRead(characteristic: BluetoothGattCharacteristic) {
+ @Suppress("DEPRECATION") val data = characteristic.value
+ Timber.d("### BluetoothLowEnergyService.onCharacteristicRead ${String(data)}")
+ if (data.isNotEmpty()) {
+ callback?.onCharacteristicRead(data)
+ }
+ }
+
+ override fun onBind(intent: Intent?): IBinder {
+ return binder
+ }
+
+ inner class LocalBinder : Binder() {
+ fun getService(): BluetoothLowEnergyService = this@BluetoothLowEnergyService
+ }
+}
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
new file mode 100644
index 00000000000..6329f59692b
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyServiceConnection.kt
@@ -0,0 +1,80 @@
+/*
+ * 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.bluetooth.BluetoothDevice
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.IBinder
+import javax.inject.Inject
+
+class BluetoothLowEnergyServiceConnection @Inject constructor(
+ private val context: Context,
+) : ServiceConnection, BluetoothLowEnergyService.Callback {
+
+ interface Callback {
+ fun onCharacteristicRead(data: ByteArray)
+ fun onConnectedToDevice(device: BluetoothDevice)
+ }
+
+ private var isBound = false
+ private var bluetoothLowEnergyService: BluetoothLowEnergyService? = null
+ private var deviceAddress: String? = null
+
+ var callback: Callback? = null
+
+ fun bind(deviceAddress: String, callback: Callback) {
+ this.deviceAddress = deviceAddress
+ this.callback = callback
+
+ if (!isBound) {
+ Intent(context, BluetoothLowEnergyService::class.java).also { intent ->
+ context.bindService(intent, this, Context.BIND_AUTO_CREATE)
+ }
+ }
+ }
+
+ override fun onServiceConnected(name: ComponentName, binder: IBinder) {
+ bluetoothLowEnergyService = (binder as BluetoothLowEnergyService.LocalBinder).getService().also {
+ it.callback = this
+ }
+
+ deviceAddress?.let {
+ bluetoothLowEnergyService?.connect(it)
+ }
+ isBound = true
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ isBound = false
+ bluetoothLowEnergyService = null
+ }
+
+ override fun onConnectedToDevice(device: BluetoothDevice) {
+ callback?.onConnectedToDevice(device)
+ }
+
+ override fun onCharacteristicRead(data: ByteArray) {
+ callback?.onCharacteristicRead(data)
+ }
+
+ fun stopService() {
+ bluetoothLowEnergyService?.stopService()
+ }
+}
diff --git a/vector/src/main/res/drawable/ic_ptt_bluetooth.xml b/vector/src/main/res/drawable/ic_ptt_bluetooth.xml
new file mode 100644
index 00000000000..cef57038a68
--- /dev/null
+++ b/vector/src/main/res/drawable/ic_ptt_bluetooth.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/vector/src/main/res/layout/fragment_room_widget.xml b/vector/src/main/res/layout/fragment_room_widget.xml
index abd85fff4d1..35b4dc72f3f 100644
--- a/vector/src/main/res/layout/fragment_room_widget.xml
+++ b/vector/src/main/res/layout/fragment_room_widget.xml
@@ -1,53 +1,93 @@
-
-
+ android:layout_height="match_parent">
+
+
+
+
+
+
-
+
+
+
+
+
-
-
+ app:behavior_hideable="true"
+ app:behavior_peekHeight="200dp"
+ app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
+ android:layout_marginVertical="30dp"
+ android:paddingHorizontal="16dp"
+ android:text="@string/push_to_talk_bottom_sheet_title" />
+
+
+
+
+
-
\ 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 00000000000..24d4a31aa60
--- /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 d2dd6614c16..81ff262c392 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
+
+
+