mirror of
https://github.com/vector-im/riotX-android
synced 2025-10-06 00:02:48 +02:00
Compare commits
50 Commits
v1.6.46
...
feature/on
Author | SHA1 | Date | |
---|---|---|---|
|
4cbf69270a | ||
|
a7ec0541f7 | ||
|
f9d44ed999 | ||
|
e388b5fe01 | ||
|
099be64f0e | ||
|
b5526906f2 | ||
|
4c46b44e78 | ||
|
f87284418f | ||
|
67e391a926 | ||
|
6bfe3ff15d | ||
|
83355a74d9 | ||
|
520eb2c1fd | ||
|
f98339c12b | ||
|
8f7e2b9623 | ||
|
897319947f | ||
|
84dca45b21 | ||
|
b3b5a5bfe6 | ||
|
706f513baf | ||
|
dd49bafabb | ||
|
39fa999a30 | ||
|
9b87f83782 | ||
|
07c0f790f0 | ||
|
48afcddd6f | ||
|
fd6fd0764b | ||
|
d595683efa | ||
|
cc12f4db4a | ||
|
03c01bde62 | ||
|
302f0cfdfc | ||
|
b5d312e467 | ||
|
ed1b861ab5 | ||
|
d955e1545a | ||
|
039a8d1c3f | ||
|
e53a644b68 | ||
|
cf4d2ed6f7 | ||
|
9090e37a0f | ||
|
75ab0aef53 | ||
|
cea7193c48 | ||
|
13b3178309 | ||
|
9ef20f46ed | ||
|
10d13256f4 | ||
|
4b128d3bc0 | ||
|
096fd83161 | ||
|
dd72201471 | ||
|
715459a160 | ||
|
7e152bd1d7 | ||
|
35dad02bd1 | ||
|
cf8056e0d8 | ||
|
09c435ae59 | ||
|
022ae91002 | ||
|
f538e91c1f |
@@ -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>
|
||||
|
@@ -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 {
|
||||
|
@@ -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"
|
||||
|
@@ -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>
|
||||
|
@@ -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
|
||||
|
@@ -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.
|
||||
*
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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.
|
||||
*/
|
||||
|
@@ -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()
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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()
|
||||
}
|
||||
|
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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(
|
||||
|
@@ -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,
|
||||
)
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
@@ -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()
|
||||
}
|
||||
}
|
6
vector/src/main/res/drawable/ic_ptt_bluetooth.xml
Normal file
6
vector/src/main/res/drawable/ic_ptt_bluetooth.xml
Normal 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>
|
@@ -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>
|
||||
|
40
vector/src/main/res/layout/item_bluetooth_device.xml
Normal file
40
vector/src/main/res/layout/item_bluetooth_device.xml
Normal 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>
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user