1
0
mirror of https://github.com/vector-im/riotX-android synced 2025-10-06 00:02:48 +02:00

Compare commits

...

50 Commits

Author SHA1 Message Date
jonnyandrew
4cbf69270a Start Element Call widget in its own task (#8004)
Start Element Call widget in its own task

So that closing the app does not end a PTT call
2023-01-25 14:33:23 +00:00
David Baker
a7ec0541f7 Just connect to any paired devices with ptt in the name 2023-01-24 21:53:29 +00:00
David Baker
f9d44ed999 Match any devices with 'kodiak' in the name 2023-01-24 21:48:49 +00:00
Jonny Andrew
e388b5fe01 Fix duplicate bluetooth button events 2023-01-24 20:22:53 +00:00
Jonny Andrew
099be64f0e Revert widget event observer behaviour
Allow events to be collected in the background
2023-01-24 20:22:15 +00:00
Jonny Andrew
b5526906f2 Merge branch 'develop' of github.com:vector-im/element-android into feature/ons/ptt_bluetooth 2023-01-24 20:13:15 +00:00
jonnyandrew
4c46b44e78 Fix element call UI touchable through bottom sheet (#7997) 2023-01-24 16:30:30 +00:00
Onuray Sahin
f87284418f Add logs to debug. 2023-01-23 15:44:13 +03:00
Onuray Sahin
67e391a926 Merge remote-tracking branch 'origin/feature/ons/ptt_bluetooth' into feature/ons/ptt_bluetooth 2023-01-20 20:14:53 +03:00
Onuray Sahin
6bfe3ff15d Start foreground service asap. 2023-01-20 20:14:40 +03:00
Jonny Andrew
83355a74d9 Update app name and logo color for demo 2023-01-20 15:10:01 +00:00
David Baker
520eb2c1fd Merge pull request #7980 from vector-im/dbkr/change_ec_url
Update to new Element Call URL
2023-01-20 09:36:00 +00:00
David Baker
f98339c12b Update to new Element Call URL
The old one has disappeared from Netlify. This replaces it with
a ROSA deployment like our live EC which shouldn't randomly go
away.
2023-01-19 17:57:23 +00:00
Jonny Andrew
8f7e2b9623 Force voice call button to trigger new flow 2023-01-17 16:48:58 +00:00
Onuray Sahin
897319947f Fix service connection on Android 12. 2022-11-03 13:34:21 +03:00
Onuray Sahin
84dca45b21 Connect bluetooth device from bottom sheet. 2022-11-02 16:54:22 +03:00
Onuray Sahin
b3b5a5bfe6 Implement bluetooth device list bottom sheet. 2022-11-02 13:57:24 +03:00
Onuray Sahin
706f513baf Support Android 12 and above. 2022-10-31 13:32:55 +03:00
Onuray Sahin
dd49bafabb Reconnect to the ptt button automatically. 2022-10-26 19:41:38 +03:00
Onuray Sahin
39fa999a30 Revert code to support devices below Android 12. 2022-10-26 14:45:04 +03:00
Onuray Sahin
9b87f83782 Refactor deprecated methods. 2022-10-25 15:42:23 +03:00
Onuray Sahin
07c0f790f0 Merge branch 'develop' into feature/ons/ptt_bluetooth
# Conflicts:
#	library/ui-strings/src/main/res/values/strings.xml
#	matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/WidgetType.kt
#	vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt
#	vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt
#	vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt
#	vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
#	vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
#	vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt
#	vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt
#	vector/src/main/java/im/vector/app/features/widgets/WidgetActivity.kt
#	vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt
#	vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt
#	vector/src/main/java/im/vector/app/features/widgets/webview/WidgetWebView.kt
2022-10-25 15:41:35 +03:00
David Baker
48afcddd6f Merge pull request #6731 from vector-im/dbkr/ptt_url_scheme
Add scheme to element call domain
2022-08-03 21:36:23 +01:00
David Baker
fd6fd0764b Add scheme to element call domain
Otherwise the widgets end up with invalid URLs
2022-08-03 16:48:27 +01:00
Onuray Sahin
d595683efa Allow default users to join an existing element call. 2022-07-12 12:33:40 +03:00
Onuray Sahin
cc12f4db4a Create element call widget if needed. 2022-07-11 17:41:58 +03:00
Onuray Sahin
03c01bde62 Add a hangup button in pip mode. 2022-07-08 15:06:44 +03:00
Onuray Sahin
302f0cfdfc Stop bluetooth service when the widget is destroyed. 2022-07-07 15:51:46 +03:00
Onuray Sahin
b5d312e467 Stop javascript for non element call widgets. 2022-07-07 13:19:40 +03:00
Onuray Sahin
ed1b861ab5 Merge pull request #6494 from vector-im/johannes/shortcut-permissions
Suppress webview / checkbox permission dialog
2022-07-07 11:43:57 +03:00
Johannes Marbach
d955e1545a Suppress webview / checkbox permission dialog
Signed-off-by: Johannes Marbach <johannesm@element.io>
2022-07-07 08:46:47 +02:00
Onuray Sahin
039a8d1c3f Fix device name. 2022-07-06 18:05:06 +03:00
Onuray Sahin
e53a644b68 Auto-connect to ptt-z devices. 2022-07-06 17:59:57 +03:00
Onuray Sahin
cf4d2ed6f7 Open element call widget directly if it is the only widget. 2022-07-06 16:47:43 +03:00
Onuray Sahin
9090e37a0f Auto grant WebView permissions if they are already granted system level. 2022-07-06 15:22:28 +03:00
Onuray Sahin
75ab0aef53 Skip widget permissions for element call. 2022-07-06 14:54:24 +03:00
Onuray Sahin
cea7193c48 Merge pull request #6476 from vector-im/dbkr/ptt_enable_notifications
Enable notifications for characteristic changes
2022-07-06 13:54:29 +03:00
Onuray Sahin
13b3178309 Request required bluetooth permission. 2022-07-06 13:54:05 +03:00
David Baker
9ef20f46ed Enable notifications for characteristic changes
by setting the appropriate descriptor, which apparently is not
a thing that setCharacteristicNotification does
2022-07-05 18:10:32 +01:00
Onuray Sahin
10d13256f4 Support picture-in-picture mode for element call widget. 2022-07-05 16:06:20 +03:00
Onuray Sahin
4b128d3bc0 Create a post message when receiving expected ptt data. 2022-07-05 14:20:58 +03:00
Onuray Sahin
096fd83161 Emit ByteArray instead of hex. 2022-07-05 13:57:32 +03:00
Onuray Sahin
dd72201471 Register to all characteristics. 2022-07-05 12:23:13 +03:00
Onuray Sahin
715459a160 Add LE flag to gatt connection. 2022-07-04 21:58:47 +03:00
Onuray Sahin
7e152bd1d7 Create a sticky service for BLE communication. 2022-07-04 21:34:01 +03:00
Onuray Sahin
35dad02bd1 Scan available BLE devices and show in a dialog. 2022-07-04 17:36:24 +03:00
Onuray Sahin
cf8056e0d8 Create custom widget args for element call. 2022-07-04 17:35:07 +03:00
Onuray Sahin
09c435ae59 Add required bluetooth permissions. 2022-07-04 17:34:22 +03:00
Onuray Sahin
022ae91002 Create BLE service. 2022-07-04 13:54:57 +03:00
Onuray Sahin
f538e91c1f Add element call widget type. 2022-07-04 13:53:10 +03:00
27 changed files with 1036 additions and 71 deletions

View File

@@ -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>
@@ -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>

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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>

View File

@@ -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" />
@@ -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" />
@@ -372,6 +379,10 @@
android:foregroundServiceType="mediaProjection"
tools:targetApi="Q" />
<service
android:name=".features.widgets.ptt.BluetoothLowEnergyService"
android:exported="false" />
<!-- Receivers -->
<receiver

View File

@@ -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
@@ -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
@@ -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.
*

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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.
*/

View File

@@ -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()
}

View File

@@ -63,6 +63,9 @@ class WidgetActivity : VectorBaseActivity<ActivityWidgetBinding>() {
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<ActivityWidgetBinding>() {
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<PictureInPictureModeChangedInfo> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return@Consumer
@@ -193,7 +200,7 @@ class WidgetActivity : VectorBaseActivity<ActivityWidgetBinding>() {
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)
}
}
}

View File

@@ -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<AudioManager>()?.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<BluetoothLowEnergyDevice>) {
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")
}
}
}

View File

@@ -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()
}

View File

@@ -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<WidgetViewState, WidgetAction, WidgetViewEvents>(initialState),
WidgetPostAPIHandler.NavigationCallback,
IntegrationManagerService.Listener {
IntegrationManagerService.Listener, BluetoothLowEnergyServiceConnection.Callback,
BluetoothLowEnergyDeviceScanner.Callback {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<WidgetViewModel, WidgetViewState> {
@@ -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
)
}
}
}

View File

@@ -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<String> = Uninitialized,
val widgetName: String = "",
val canManageWidgets: Boolean = false,
val asyncWidget: Async<Widget> = Uninitialized
val asyncWidget: Async<Widget> = Uninitialized,
val bluetoothDeviceList: List<BluetoothLowEnergyDevice> = emptyList(),
) : MavericksState {
constructor(widgetArgs: WidgetArgs) : this(

View File

@@ -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,
)

View File

@@ -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<BluetoothLowEnergyDeviceItem.Holder>(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<TextView>(R.id.bluetoothDeviceNameTextView)
val bluetoothDeviceMacAddressTextView by bind<TextView>(R.id.bluetoothDeviceMacAddressTextView)
val bluetoothDeviceConnectionStatusTextView by bind<TextView>(R.id.bluetoothDeviceConnectionStatusTextView)
}
}

View File

@@ -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<BluetoothManager>()
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)
}
}

View File

@@ -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<BluetoothLowEnergyDevice>? = null
var callback: Callback? = null
fun setData(deviceList: List<BluetoothLowEnergyDevice>) {
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)
}
}
}
}

View File

@@ -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<BluetoothManager>()
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
}
}

View File

@@ -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()
}
}

View File

@@ -0,0 +1,6 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#ffffff" android:pathData="M10.882,3.159L5.455,7.682L1.364,7.682C0.611,7.682 0,8.292 0,9.046V15.046C0,15.799 0.611,16.409 1.364,16.409L5.455,16.409L10.882,20.932C11.326,21.302 12,20.986 12,20.408V3.683C12,3.105 11.326,2.789 10.882,3.159Z"/>
<path android:fillColor="#ffffff" android:pathData="M23.666,7.824L20.089,4.247C19.563,3.722 18.663,4.089 18.663,4.831V9.984L15.42,6.74C15.094,6.415 14.569,6.415 14.244,6.74C13.919,7.065 13.919,7.591 14.244,7.916L18.321,11.993L14.244,16.07C13.919,16.395 13.919,16.921 14.244,17.246C14.569,17.571 15.094,17.571 15.42,17.246L18.663,14.002V19.155C18.663,19.897 19.563,20.272 20.089,19.747L23.666,16.162C23.991,15.837 23.991,15.311 23.666,14.986L20.672,11.993L23.666,9.008C23.991,8.683 23.991,8.149 23.666,7.824ZM20.33,6.849L21.898,8.416L20.33,9.984V6.849ZM21.898,15.57L20.33,17.138V14.002L21.898,15.57V15.57Z"/>
</vector>

View File

@@ -1,53 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/widgetWebView"
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignParentBottom="true"
android:layout_marginBottom="0dp"
android:background="@android:color/transparent" />
android:layout_height="match_parent">
<ProgressBar
android:id="@+id/widgetProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:indeterminate="true" />
<WebView
android:id="@+id/widgetWebView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignParentBottom="true"
android:layout_marginBottom="0dp"
android:background="@android:color/transparent" />
<LinearLayout
android:id="@+id/widgetErrorLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:background="?colorSurface"
android:orientation="horizontal"
android:padding="16dp"
android:visibility="gone"
tools:visibility="visible">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/error" />
<TextView
android:id="@+id/widgetErrorText"
style="@style/Widget.Vector.TextView.Subtitle"
<ProgressBar
android:id="@+id/widgetProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:textColor="?vctr_content_primary"
android:textStyle="bold"
tools:text="Fail to load widget " />
android:layout_centerInParent="true"
android:indeterminate="true" />
<LinearLayout
android:id="@+id/widgetErrorLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:background="?colorSurface"
android:orientation="horizontal"
android:padding="16dp"
android:visibility="gone"
tools:visibility="visible">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/error" />
<TextView
android:id="@+id/widgetErrorText"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:textColor="?vctr_content_primary"
android:textStyle="bold"
tools:text="Fail to load widget " />
</LinearLayout>
</RelativeLayout>
<LinearLayout
android:id="@+id/bottomSheet"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?vctr_system"
android:clickable="true"
android:focusable="true"
android:orientation="vertical"
android:visibility="gone"
app:behavior_hideable="true"
app:behavior_peekHeight="200dp"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
<TextView
style="@style/TextAppearance.Vector.Headline.Medium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginVertical="30dp"
android:paddingHorizontal="16dp"
android:text="@string/push_to_talk_bottom_sheet_title" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?vctr_list_separator" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/widgetBluetoothListRecyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:listitem="@layout/item_bluetooth_device" />
</LinearLayout>
</RelativeLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/bluetoothDeviceNameTextView"
style="@style/TextAppearance.Vector.Body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="30dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Device 1" />
<TextView
android:id="@+id/bluetoothDeviceMacAddressTextView"
style="@style/TextAppearance.Vector.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@id/bluetoothDeviceNameTextView"
app:layout_constraintTop_toBottomOf="@id/bluetoothDeviceNameTextView"
tools:text="00:1B:44:11:3A:B7" />
<TextView
android:id="@+id/bluetoothDeviceConnectionStatusTextView"
style="@style/TextAppearance.Vector.Body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
app:layout_constraintBottom_toBottomOf="@id/bluetoothDeviceMacAddressTextView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/bluetoothDeviceNameTextView"
tools:text="Disconnected"
tools:textColor="?colorError" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -27,4 +27,10 @@
android:title="@string/room_widget_revoke_access"
app:showAsAction="never" />
</menu>
<item
android:id="@+id/action_push_to_talk"
android:title="@string/action_push_to_talk_configure_device"
android:icon="@drawable/ic_ptt_bluetooth"
app:showAsAction="ifRoom" />
</menu>