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 19 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
13 changes: 12 additions & 1 deletion vector/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<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 +314,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 +392,10 @@
android:foregroundServiceType="mediaProjection"
tools:targetApi="Q" />

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

<!-- Receivers -->

<receiver
Expand Down
55 changes: 55 additions & 0 deletions vector/src/main/java/im/vector/app/core/utils/PermissionsTools.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ package im.vector.app.core.utils
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.os.Build
import android.webkit.PermissionRequest
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
Expand All @@ -43,6 +45,33 @@ val PERMISSIONS_FOR_WRITING_FILES = listOf(Manifest.permission.WRITE_EXTERNAL_ST
val PERMISSIONS_FOR_PICKING_CONTACT = listOf(Manifest.permission.READ_CONTACTS)
val PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING = listOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)

// See https://developer.android.com/guide/topics/connectivity/bluetooth/permissions
val PERMISSIONS_FOR_BLUETOOTH = when {
Build.VERSION.SDK_INT >= 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

Expand Down Expand Up @@ -137,6 +166,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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,6 @@ sealed class RoomDetailViewEvents : VectorViewEvents {
object RoomReplacementStarted : RoomDetailViewEvents()

data class ChangeLocationIndicator(val isVisible: Boolean) : RoomDetailViewEvents()

object OpenElementCallWidget : RoomDetailViewEvents()
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ data class RoomDetailViewState(
// It can differs for a short period of time on the JitsiState as its computed async.
fun hasActiveJitsiWidget() = activeRoomWidgets()?.any { it.type == WidgetType.Jitsi && it.isActive }.orFalse()

fun hasActiveElementCallWidget() = activeRoomWidgets()?.any { it.type == WidgetType.ElementCall && it.isActive }.orFalse()

fun isDm() = asyncRoomSummary()?.isDirect == true

fun isThreadTimeline() = rootThreadEventId != null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,7 @@ class TimelineFragment @Inject constructor(
is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it)
RoomDetailViewEvents.RoomReplacementStarted -> handleRoomReplacement()
is RoomDetailViewEvents.ChangeLocationIndicator -> handleChangeLocationIndicator(it)
RoomDetailViewEvents.OpenElementCallWidget -> handleOpenElementCallWidget()
}
}

Expand Down Expand Up @@ -1099,7 +1100,17 @@ class TimelineFragment @Inject constructor(
val matrixAppsMenuItem = menu.findItem(R.id.open_matrix_apps)
val widgetsCount = state.activeRoomWidgets.invoke()?.size ?: 0
val hasOnlyJitsiWidget = widgetsCount == 1 && state.hasActiveJitsiWidget()
if (widgetsCount == 0 || hasOnlyJitsiWidget) {
val hasOnlyElementCallWidget = widgetsCount == 1 && state.hasActiveElementCallWidget()
if (hasOnlyElementCallWidget) {
val actionView = matrixAppsMenuItem.actionView
actionView.findViewById<TextView>(R.id.cart_badge).isVisible = false
actionView
.findViewById<ImageView>(R.id.action_view_icon_image)
.apply {
setImageResource(R.drawable.ic_phone)
setColorFilter(ThemeUtils.getColor(requireContext(), R.attr.colorPrimary))
}
} else if (widgetsCount == 0 || hasOnlyJitsiWidget) {
// icon should be default color no badge
val actionView = matrixAppsMenuItem.actionView
actionView
Expand Down Expand Up @@ -2638,6 +2649,15 @@ class TimelineFragment @Inject constructor(
.show(childFragmentManager, "ROOM_WIDGETS_BOTTOM_SHEET")
}

private fun handleOpenElementCallWidget() = withState(timelineViewModel) { state ->
state
.activeRoomWidgets()
?.find { it.type == WidgetType.ElementCall }
?.also { widget ->
navigator.openRoomWidget(requireContext(), state.roomId, widget)
}
}

override fun onTapToReturnToCall() {
callManager.getCurrentCall()?.let { call ->
VectorCallActivity.newIntent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,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 {
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 @@ -84,29 +87,36 @@ class WidgetActivity : VectorBaseActivity<ActivityWidgetBinding>() {
}
}

permissionViewModel.observeViewEvents {
when (it) {
is RoomWidgetPermissionViewEvents.Close -> finish()
// Trust element call widget by default
if (widgetArgs.kind == WidgetKind.ELEMENT_CALL) {
if (supportFragmentManager.findFragmentByTag(WIDGET_FRAGMENT_TAG) == null) {
addFragment(views.fragmentContainer, WidgetFragment::class.java, widgetArgs, WIDGET_FRAGMENT_TAG)
}
}

viewModel.onEach(WidgetViewState::status) { ws ->
when (ws) {
WidgetStatus.UNKNOWN -> {
} else {
permissionViewModel.observeViewEvents {
when (it) {
is RoomWidgetPermissionViewEvents.Close -> finish()
}
WidgetStatus.WIDGET_NOT_ALLOWED -> {
val dFrag = supportFragmentManager.findFragmentByTag(WIDGET_PERMISSION_FRAGMENT_TAG) as? RoomWidgetPermissionBottomSheet
if (dFrag != null && dFrag.dialog?.isShowing == true && !dFrag.isRemoving) {
return@onEach
} else {
RoomWidgetPermissionBottomSheet
.newInstance(widgetArgs)
.show(supportFragmentManager, WIDGET_PERMISSION_FRAGMENT_TAG)
}

viewModel.onEach(WidgetViewState::status) { ws ->
when (ws) {
WidgetStatus.UNKNOWN -> {
}
}
WidgetStatus.WIDGET_ALLOWED -> {
if (supportFragmentManager.findFragmentByTag(WIDGET_FRAGMENT_TAG) == null) {
addFragment(views.fragmentContainer, WidgetFragment::class.java, widgetArgs, WIDGET_FRAGMENT_TAG)
WidgetStatus.WIDGET_NOT_ALLOWED -> {
val dFrag = supportFragmentManager.findFragmentByTag(WIDGET_PERMISSION_FRAGMENT_TAG) as? RoomWidgetPermissionBottomSheet
if (dFrag != null && dFrag.dialog?.isShowing == true && !dFrag.isRemoving) {
return@onEach
} else {
RoomWidgetPermissionBottomSheet
.newInstance(widgetArgs)
.show(supportFragmentManager, WIDGET_PERMISSION_FRAGMENT_TAG)
}
}
WidgetStatus.WIDGET_ALLOWED -> {
if (supportFragmentManager.findFragmentByTag(WIDGET_FRAGMENT_TAG) == null) {
addFragment(views.fragmentContainer, WidgetFragment::class.java, widgetArgs, WIDGET_FRAGMENT_TAG)
}
}
}
}
Expand All @@ -121,6 +131,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
Loading