Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions library/ui-strings/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@
<string name="denied_permission_generic">Some permissions are missing to perform this action, please grant the permissions from the system settings.</string>
<string name="denied_permission_camera">To perform this action, please grant the Camera permission from the system settings.</string>
<string name="denied_permission_voice_message">To send voice messages, please grant the Microphone permission.</string>
<string name="denied_permission_bluetooth">To perform this action, please grant the Bluetooth permission from the system settings.</string>

<string name="missing_permissions_title">Missing permissions</string>
<string name="no_permissions_to_start_conf_call">You do not have permission to start a conference call in this room</string>
Expand Down Expand Up @@ -3316,6 +3317,15 @@
<string name="live_location_labs_promotion_description">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.</string>
<string name="live_location_labs_promotion_switch_title">Enable location sharing</string>

<!-- Element Call Widget - Push to Talk -->
<string name="push_to_talk_notification_title">${app_name} Push to Talk</string>
<string name="push_to_talk_notification_description">A service is running to communicate with BLE device</string>
<string name="push_to_talk_activity_title">Walkie-Talkie Call</string>
<string name="action_push_to_talk_configure_device">Configure push to talk device</string>
<string name="push_to_talk_bottom_sheet_title">Bluetooth</string>
<string name="push_to_talk_device_connected">Connected</string>
<string name="push_to_talk_device_disconnected">Disconnected</string>

<plurals name="room_removed_messages">
<item quantity="one">%d message removed</item>
<item quantity="other">%d messages removed</item>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions vector-app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def generateVersionCodeFromVersionName() {
}

def getVersionCode() {
return (Integer.MAX_VALUE / 100).toInteger()
if (gitBranchName() == "develop") {
return generateVersionCodeFromTimestamp()
} else {
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions vector-config/src/main/res/values/config.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
<!-- preferred jitsi domain -->
<string name="preferred_jitsi_domain" translatable="false">meet.element.io</string>

<!-- preferred element call domain -->
<string name="preferred_element_call_domain" translatable="false">https://call-ptt.lab.element.dev/room/?embed</string>

<string-array name="room_directory_servers" translatable="false">
<item>matrix.org</item>
<item>gitter.im</item>
Expand Down
11 changes: 11 additions & 0 deletions vector/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,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="tiramisu" />
<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 @@ -278,6 +284,7 @@
<activity android:name=".features.terms.ReviewTermsActivity" />
<activity
android:name=".features.widgets.WidgetActivity"
android:taskAffinity=".features.widgets.WidgetActivity.${appTaskAffinitySuffix}"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:supportsPictureInPicture="true" />

Expand Down Expand Up @@ -372,6 +379,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 @@ -44,6 +46,33 @@ 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)
val PERMISSIONS_FOR_VOICE_BROADCAST = listOf(Manifest.permission.RECORD_AUDIO)

// 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 @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Loading