Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bluetooth-assisted Push to Talk (PTT) via Element Call #6464

Draft
wants to merge 50 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
f538e91
Add element call widget type.
onurays Jul 4, 2022
022ae91
Create BLE service.
onurays Jul 4, 2022
09c435a
Add required bluetooth permissions.
onurays Jul 4, 2022
cf8056e
Create custom widget args for element call.
onurays Jul 4, 2022
35dad02
Scan available BLE devices and show in a dialog.
onurays Jul 4, 2022
7e152bd
Create a sticky service for BLE communication.
onurays Jul 4, 2022
715459a
Add LE flag to gatt connection.
onurays Jul 4, 2022
dd72201
Register to all characteristics.
onurays Jul 5, 2022
096fd83
Emit ByteArray instead of hex.
onurays Jul 5, 2022
4b128d3
Create a post message when receiving expected ptt data.
onurays Jul 5, 2022
10d1325
Support picture-in-picture mode for element call widget.
onurays Jul 5, 2022
9ef20f4
Enable notifications for characteristic changes
dbkr Jul 5, 2022
13b3178
Request required bluetooth permission.
onurays Jul 6, 2022
cea7193
Merge pull request #6476 from vector-im/dbkr/ptt_enable_notifications
onurays Jul 6, 2022
75ab0ae
Skip widget permissions for element call.
onurays Jul 6, 2022
9090e37
Auto grant WebView permissions if they are already granted system level.
onurays Jul 6, 2022
cf4d2ed
Open element call widget directly if it is the only widget.
onurays Jul 6, 2022
e53a644
Auto-connect to ptt-z devices.
onurays Jul 6, 2022
039a8d1
Fix device name.
onurays Jul 6, 2022
d955e15
Suppress webview / checkbox permission dialog
Johennes Jul 7, 2022
ed1b861
Merge pull request #6494 from vector-im/johannes/shortcut-permissions
onurays Jul 7, 2022
b5d312e
Stop javascript for non element call widgets.
onurays Jul 7, 2022
302f0cf
Stop bluetooth service when the widget is destroyed.
onurays Jul 7, 2022
03c01bd
Add a hangup button in pip mode.
onurays Jul 8, 2022
cc12f4d
Create element call widget if needed.
onurays Jul 11, 2022
d595683
Allow default users to join an existing element call.
onurays Jul 12, 2022
fd6fd07
Add scheme to element call domain
dbkr Aug 3, 2022
48afcdd
Merge pull request #6731 from vector-im/dbkr/ptt_url_scheme
dbkr Aug 3, 2022
07c0f79
Merge branch 'develop' into feature/ons/ptt_bluetooth
Oct 25, 2022
9b87f83
Refactor deprecated methods.
Oct 25, 2022
39fa999
Revert code to support devices below Android 12.
Oct 26, 2022
dd49baf
Reconnect to the ptt button automatically.
Oct 26, 2022
706f513
Support Android 12 and above.
Oct 31, 2022
b3b5a5b
Implement bluetooth device list bottom sheet.
Nov 2, 2022
84dca45
Connect bluetooth device from bottom sheet.
Nov 2, 2022
8973199
Fix service connection on Android 12.
Nov 3, 2022
8f7e2b9
Force voice call button to trigger new flow
jonnyandrew Jan 17, 2023
f98339c
Update to new Element Call URL
dbkr Jan 19, 2023
520eb2c
Merge pull request #7980 from vector-im/dbkr/change_ec_url
dbkr Jan 20, 2023
83355a7
Update app name and logo color for demo
jonnyandrew Jan 20, 2023
6bfe3ff
Start foreground service asap.
onurays Jan 20, 2023
67e391a
Merge remote-tracking branch 'origin/feature/ons/ptt_bluetooth' into …
onurays Jan 20, 2023
f872844
Add logs to debug.
onurays Jan 23, 2023
4c46b44
Fix element call UI touchable through bottom sheet (#7997)
jonnyandrew Jan 24, 2023
b552690
Merge branch 'develop' of github.com:vector-im/element-android into f…
jonnyandrew Jan 24, 2023
099be64
Revert widget event observer behaviour
jonnyandrew Jan 24, 2023
e388b5f
Fix duplicate bluetooth button events
jonnyandrew Jan 24, 2023
f9d44ed
Match any devices with 'kodiak' in the name
dbkr Jan 24, 2023
a7ec054
Just connect to any paired devices with ptt in the name
dbkr Jan 24, 2023
4cbf692
Start Element Call widget in its own task (#8004)
jonnyandrew Jan 25, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ private val DEFINED_TYPES by lazy {
WidgetType.StickerPicker,
WidgetType.Grafana,
WidgetType.Custom,
WidgetType.IntegrationManager
WidgetType.IntegrationManager,
WidgetType.ElementCall
)
}

Expand All @@ -47,6 +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")
data class Fallback(override val preferred: String) : WidgetType(preferred)

fun matches(type: String): Boolean {
Expand Down
9 changes: 8 additions & 1 deletion vector/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
Expand Down Expand Up @@ -308,7 +310,8 @@
<activity android:name=".features.terms.ReviewTermsActivity" />
<activity
android:name=".features.widgets.WidgetActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" />
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:supportsPictureInPicture="true" />

<activity android:name=".features.pin.PinActivity" />
<activity android:name=".features.analytics.ui.consent.AnalyticsOptInActivity" />
Expand Down Expand Up @@ -385,6 +388,10 @@
android:foregroundServiceType="mediaProjection"
tools:targetApi="Q" />

<service
android:name=".features.widgets.ptt.BluetoothLowEnergyService"
android:exported="false" />

<!-- Receivers -->

<receiver
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,9 @@ class DefaultNavigator @Inject constructor(
val enableVideo = options?.get(JitsiCallViewModel.ENABLE_VIDEO_OPTION) == true
context.startActivity(VectorJitsiActivity.newIntent(context, roomId = roomId, widgetId = widget.widgetId, enableVideo = enableVideo))
}
} else if (widget.type is WidgetType.ElementCall) {
val widgetArgs = widgetArgsBuilder.buildElementCallWidgetArgs(roomId, widget)
context.startActivity(WidgetActivity.newIntent(context, widgetArgs))
} else {
val widgetArgs = widgetArgsBuilder.buildRoomWidgetArgs(roomId, widget)
context.startActivity(WidgetActivity.newIntent(context, widgetArgs))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,19 @@ class NotificationUtils @Inject constructor(
.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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package im.vector.app.features.widgets

import android.bluetooth.BluetoothDevice
import im.vector.app.core.platform.VectorViewModelAction

sealed class WidgetAction : VectorViewModelAction {
Expand All @@ -26,4 +27,5 @@ sealed class WidgetAction : VectorViewModelAction {
object DeleteWidget : WidgetAction()
object RevokeWidget : WidgetAction()
object OnTermsReviewed : WidgetAction()
data class ConnectToBluetoothDevice(val device: BluetoothDevice) : WidgetAction()
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@
package im.vector.app.features.widgets

import android.app.Activity
import android.app.PictureInPictureParams
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Rational
import androidx.core.view.isVisible
import com.airbnb.mvrx.Mavericks
import com.airbnb.mvrx.viewModel
Expand Down Expand Up @@ -121,6 +124,24 @@ class WidgetActivity : VectorBaseActivity<ActivityWidgetBinding>() {
}
}

override fun onUserLeaveHint() {
super.onUserLeaveHint()
val widgetArgs: WidgetArgs? = intent?.extras?.getParcelable(Mavericks.KEY_ARG)
if (widgetArgs?.kind == WidgetKind.ELEMENT_CALL) {
enterPictureInPicture()
}
}

private fun enterPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val aspectRatio = Rational(resources.getDimensionPixelSize(R.dimen.call_pip_width), resources.getDimensionPixelSize(R.dimen.call_pip_height))
val params = PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio)
.build()
enterPictureInPictureMode(params)
}
}

private fun handleClose(event: WidgetViewEvents.Close) {
if (event.content != null) {
val intent = createResultIntent(event.content)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ class WidgetArgsBuilder @Inject constructor(
)
}

fun buildElementCallWidgetArgs(roomId: String, widget: Widget): WidgetArgs {
return buildRoomWidgetArgs(roomId, widget)
.copy(
kind = WidgetKind.ELEMENT_CALL
)
}

@Suppress("UNCHECKED_CAST")
private fun Map<String, String?>.filterNotNull(): Map<String, String> {
return filterValues { it != null } as Map<String, String>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@
package im.vector.app.features.widgets

import android.app.Activity
import android.bluetooth.BluetoothDevice

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 🚫 Unused import

import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
Expand All @@ -27,7 +32,10 @@ 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.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import com.airbnb.mvrx.Fail
Expand All @@ -45,10 +53,13 @@ import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.openUrlInExternalBrowser
import im.vector.app.databinding.FragmentRoomWidgetBinding
import im.vector.app.features.webview.WebEventListener
import im.vector.app.features.widgets.ptt.BluetoothLowEnergyDeviceScanner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 🚫 Unused import

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 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
Expand All @@ -64,7 +75,8 @@ data class WidgetArgs(
) : Parcelable

class WidgetFragment @Inject constructor(
private val permissionUtils: WebviewPermissionUtils
private val permissionUtils: WebviewPermissionUtils,
private val bluetoothLowEnergyDeviceScanner: BluetoothLowEnergyDeviceScanner,
) :
VectorBaseFragment<FragmentRoomWidgetBinding>(),
WebEventListener,
Expand All @@ -84,6 +96,11 @@ class WidgetFragment @Inject constructor(
if (fragmentArgs.kind.isAdmin()) {
viewModel.getPostAPIMediator().setWebView(views.widgetWebView)
}

if (fragmentArgs.kind == WidgetKind.ELEMENT_CALL) {
startBluetoothScanning()
}

viewModel.observeViewEvents {
Timber.v("Observed view events: $it")
when (it) {
Expand All @@ -92,6 +109,7 @@ class WidgetFragment @Inject constructor(
is WidgetViewEvents.DisplayIntegrationManager -> displayIntegrationManager(it)
is WidgetViewEvents.Failure -> displayErrorDialog(it.throwable)
is WidgetViewEvents.Close -> Unit
is WidgetViewEvents.OnBluetoothDeviceData -> handleBluetoothDeviceData(it)
}
}
viewModel.handle(WidgetAction.LoadFormattedUrl)
Expand Down Expand Up @@ -128,14 +146,6 @@ class WidgetFragment @Inject constructor(
}
}

override fun onPause() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we possibly want to keep this for non-Element-Call widgets?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly, it is in my to-do list. Nice catch though, thanks.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

super.onPause()
views.widgetWebView.let {
it.pauseTimers()
it.onPause()
}
}

override fun onPrepareOptionsMenu(menu: Menu) = withState(viewModel) { state ->
val widget = state.asyncWidget()
menu.findItem(R.id.action_edit)?.isVisible = state.widgetKind != WidgetKind.INTEGRATION_MANAGER
Expand Down Expand Up @@ -340,4 +350,58 @@ class WidgetFragment @Inject constructor(
private fun revokeWidget() {
viewModel.handle(WidgetAction.RevokeWidget)
}

private var deviceListDialog: AlertDialog? = null

private fun startBluetoothScanning() {
val deviceListDialogBuilder = MaterialAlertDialogBuilder(requireContext())
val bluetoothDevices = mutableListOf<BluetoothDevice>()

bluetoothLowEnergyDeviceScanner.callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
super.onScanResult(callbackType, result)
Timber.d("### WidgetFragment. New BLE device found: " + result.device.name + " - " + result.device.address)
if (result.device.name == null) {
return
}
bluetoothDevices.add(result.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()
}
}
bluetoothLowEnergyDeviceScanner.startScanning()
}

private fun onBluetoothDeviceSelected(device: BluetoothDevice) {
viewModel.handle(WidgetAction.ConnectToBluetoothDevice(device))

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)

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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ 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.BluetoothLowEnergyServiceConnection
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
Expand All @@ -52,11 +53,12 @@ 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,
) :
VectorViewModel<WidgetViewState, WidgetAction, WidgetViewEvents>(initialState),
WidgetPostAPIHandler.NavigationCallback,
IntegrationManagerService.Listener {
IntegrationManagerService.Listener, BluetoothLowEnergyServiceConnection.Callback {

@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<WidgetViewModel, WidgetViewState> {
Expand Down Expand Up @@ -147,9 +149,14 @@ class WidgetViewModel @AssistedInject constructor(
WidgetAction.DeleteWidget -> handleDeleteWidget()
WidgetAction.RevokeWidget -> handleRevokeWidget()
WidgetAction.OnTermsReviewed -> loadFormattedUrl(forceFetchToken = false)
is WidgetAction.ConnectToBluetoothDevice -> handleConnectToBluetoothDevice(action)
}
}

private fun handleConnectToBluetoothDevice(action: WidgetAction.ConnectToBluetoothDevice) {
bluetoothLowEnergyServiceConnection.bind(action.device, this)
}

private fun handleRevokeWidget() {
viewModelScope.launch {
val widgetId = initialState.widgetId ?: return@launch
Expand Down Expand Up @@ -296,4 +303,8 @@ class WidgetViewModel @AssistedInject constructor(
override fun openIntegrationManager(integId: String?, integType: String?) {
_viewEvents.post(WidgetViewEvents.DisplayIntegrationManager(integId, integType))
}

override fun onCharacteristicRead(data: ByteArray) {
_viewEvents.post(WidgetViewEvents.OnBluetoothDeviceData(data))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ enum class WidgetStatus {
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);
INTEGRATION_MANAGER(0, null),
ELEMENT_CALL(0, null);

fun isAdmin(): Boolean {
return this == STICKER_PICKER || this == INTEGRATION_MANAGER
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* 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.BluetoothManager
import android.bluetooth.le.ScanCallback
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

class BluetoothLowEnergyDeviceScanner @Inject constructor(
context: Context
) {

private val bluetoothManager = context.getSystemService<BluetoothManager>()

var callback: ScanCallback? = null

fun startScanning() {
bluetoothManager
?.adapter
?.bluetoothLeScanner
?.startScan(callback)

Handler(Looper.getMainLooper()).postDelayed({
stopScanning()
}, 10_000)
}

private fun stopScanning() {
bluetoothManager?.adapter?.bluetoothLeScanner?.stopScan(callback)
}
}
Loading