From 7e152bd1d71ead9c2d39ffe669b6e0d222ca9a7f Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Mon, 4 Jul 2022 21:34:01 +0300 Subject: [PATCH] Create a sticky service for BLE communication. --- .../notifications/NotificationUtils.kt | 13 ++++ .../app/features/widgets/WidgetAction.kt | 2 + .../app/features/widgets/WidgetFragment.kt | 15 +++- .../app/features/widgets/WidgetViewEvents.kt | 1 + .../app/features/widgets/WidgetViewModel.kt | 15 +++- .../widgets/ptt/BluetoothLowEnergyService.kt | 57 ++++++++++++++- .../BluetoothLowEnergyServiceConnection.kt | 71 +++++++++++++++++++ vector/src/main/res/values/strings.xml | 4 ++ 8 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyServiceConnection.kt diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt index c0fc231c8a..a9403ba594 100755 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt @@ -552,6 +552,19 @@ class NotificationUtils @Inject constructor( .build() } + /** + * Creates a notification that indicates the application is communicating with a BLE device mainly for push-to-talk in Element Call Widget. + */ + fun buildBluetoothLowEnergyNotification(): Notification { + return NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID) + .setContentTitle(stringProvider.getString(R.string.push_to_talk_notification_title)) + .setContentText(stringProvider.getString(R.string.push_to_talk_notification_description)) + .setSmallIcon(R.drawable.quantum_ic_bluetooth_audio_white_36) + .setColor(ThemeUtils.getColor(context, android.R.attr.colorPrimary)) + .setContentIntent(buildOpenHomePendingIntentForSummary()) + .build() + } + /** * Creates a notification that indicates the application is capturing the screen. */ diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt index b72ea68b7f..42482351fe 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt @@ -16,6 +16,7 @@ package im.vector.app.features.widgets +import android.bluetooth.BluetoothDevice import im.vector.app.core.platform.VectorViewModelAction sealed class WidgetAction : VectorViewModelAction { @@ -26,4 +27,5 @@ sealed class WidgetAction : VectorViewModelAction { object DeleteWidget : WidgetAction() object RevokeWidget : WidgetAction() object OnTermsReviewed : WidgetAction() + data class ConnectToBluetoothDevice(val device: BluetoothDevice) : WidgetAction() } diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt index d1f267cd69..f381a9c4da 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt @@ -32,6 +32,7 @@ import android.view.ViewGroup import android.webkit.PermissionRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat import androidx.core.view.isInvisible import androidx.core.view.isVisible import com.airbnb.mvrx.Fail @@ -50,6 +51,7 @@ import im.vector.app.core.utils.openUrlInExternalBrowser import im.vector.app.databinding.FragmentRoomWidgetBinding import im.vector.app.features.webview.WebEventListener import im.vector.app.features.widgets.ptt.BluetoothLowEnergyDeviceScanner +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 @@ -104,6 +106,7 @@ class WidgetFragment @Inject constructor( is WidgetViewEvents.DisplayIntegrationManager -> displayIntegrationManager(it) is WidgetViewEvents.Failure -> displayErrorDialog(it.throwable) is WidgetViewEvents.Close -> Unit + is WidgetViewEvents.OnBluetoothDeviceData -> handleBluetoothDeviceData(it) } } viewModel.handle(WidgetAction.LoadFormattedUrl) @@ -377,6 +380,16 @@ class WidgetFragment @Inject constructor( } private fun onBluetoothDeviceSelected(device: BluetoothDevice) { - + viewModel.handle(WidgetAction.ConnectToBluetoothDevice(device)) + + Intent(requireContext(), BluetoothLowEnergyService::class.java).also { + ContextCompat.startForegroundService(requireContext(), it) + } + } + + private fun handleBluetoothDeviceData(event: WidgetViewEvents.OnBluetoothDeviceData) { + activity?.let { + views.widgetWebView.evaluateJavascript("alert(${event.data})", null) + } } } diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewEvents.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewEvents.kt index 34e5c794f7..5fdfd4d366 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewEvents.kt @@ -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: String) : WidgetViewEvents() } diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt index b3f4712815..060a9e1c76 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt @@ -29,6 +29,7 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.StringProvider import im.vector.app.features.widgets.permissions.WidgetPermissionsHelper +import im.vector.app.features.widgets.ptt.BluetoothLowEnergyServiceConnection import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -52,11 +53,12 @@ class WidgetViewModel @AssistedInject constructor( @Assisted val initialState: WidgetViewState, widgetPostAPIHandlerFactory: WidgetPostAPIHandler.Factory, private val stringProvider: StringProvider, - private val session: Session + private val session: Session, + private val bluetoothLowEnergyServiceConnection: BluetoothLowEnergyServiceConnection, ) : VectorViewModel(initialState), WidgetPostAPIHandler.NavigationCallback, - IntegrationManagerService.Listener { + IntegrationManagerService.Listener, BluetoothLowEnergyServiceConnection.Callback { @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { @@ -147,9 +149,14 @@ class WidgetViewModel @AssistedInject constructor( WidgetAction.DeleteWidget -> handleDeleteWidget() WidgetAction.RevokeWidget -> handleRevokeWidget() WidgetAction.OnTermsReviewed -> loadFormattedUrl(forceFetchToken = false) + is WidgetAction.ConnectToBluetoothDevice -> handleConnectToBluetoothDevice(action) } } + private fun handleConnectToBluetoothDevice(action: WidgetAction.ConnectToBluetoothDevice) { + bluetoothLowEnergyServiceConnection.bind(action.device, this) + } + private fun handleRevokeWidget() { viewModelScope.launch { val widgetId = initialState.widgetId ?: return@launch @@ -296,4 +303,8 @@ class WidgetViewModel @AssistedInject constructor( override fun openIntegrationManager(integId: String?, integType: String?) { _viewEvents.post(WidgetViewEvents.DisplayIntegrationManager(integId, integType)) } + + override fun onCharacteristicRead(data: String) { + _viewEvents.post(WidgetViewEvents.OnBluetoothDeviceData(data)) + } } diff --git a/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyService.kt b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyService.kt index d87f80d3fd..0bbadf7f5d 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyService.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyService.kt @@ -19,18 +19,36 @@ package im.vector.app.features.widgets.ptt import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothProfile +import android.content.Intent +import android.os.Binder +import android.os.IBinder import im.vector.app.core.services.VectorService import androidx.core.content.getSystemService +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.features.notifications.NotificationUtils import timber.log.Timber +import javax.inject.Inject +import kotlin.random.Random +@AndroidEntryPoint class BluetoothLowEnergyService : VectorService() { - private val bluetoothManager = getSystemService() + interface Callback { + fun onCharacteristicRead(data: String) + } + + @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) { @@ -43,15 +61,31 @@ class BluetoothLowEnergyService : VectorService() { BluetoothProfile.STATE_DISCONNECTED -> Timber.d("### BluetoothLowEnergyService.newState: STATE_DISCONNECTED") } } + + override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { + if (status == BluetoothGatt.GATT_SUCCESS) { + onCharacteristicRead(characteristic) + } + } + + 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 + } + private fun initializeBluetoothAdapter() { + val bluetoothManager = getSystemService() bluetoothAdapter = bluetoothManager?.adapter } @@ -60,4 +94,23 @@ class BluetoothLowEnergyService : VectorService() { ?.getRemoteDevice(address) ?.connectGatt(applicationContext, false, gattCallback) } + + private fun onCharacteristicRead(characteristic: BluetoothGattCharacteristic) { + val data = characteristic.value + if (data.isNotEmpty()) { + val stringBuilder = StringBuilder() + data.forEach { + stringBuilder.append(String.format("%02X ", it)) + } + callback?.onCharacteristicRead(stringBuilder.toString()) + } + } + + override fun onBind(intent: Intent?): IBinder { + return binder + } + + inner class LocalBinder : Binder() { + fun getService(): BluetoothLowEnergyService = this@BluetoothLowEnergyService + } } diff --git a/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyServiceConnection.kt b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyServiceConnection.kt new file mode 100644 index 0000000000..97b09629f8 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/widgets/ptt/BluetoothLowEnergyServiceConnection.kt @@ -0,0 +1,71 @@ +/* + * 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: String) + } + + private var isBound = false + private var bluetoothLowEnergyService: BluetoothLowEnergyService? = null + private var bluetoothDevice: BluetoothDevice? = null + + var callback: Callback? = null + + fun bind(device: BluetoothDevice, callback: Callback) { + this.bluetoothDevice = device + this.callback = callback + + if (!isBound) { + Intent(context, BluetoothLowEnergyService::class.java).also { intent -> + context.bindService(intent, this, 0) + } + } + } + + override fun onServiceConnected(name: ComponentName, binder: IBinder) { + bluetoothLowEnergyService = (binder as BluetoothLowEnergyService.LocalBinder).getService().also { + it.callback = this + } + + bluetoothDevice?.address?.let { + bluetoothLowEnergyService?.connect(it) + } + isBound = true + } + + override fun onServiceDisconnected(name: ComponentName?) { + isBound = false + bluetoothLowEnergyService = null + } + + override fun onCharacteristicRead(data: String) { + callback?.onCharacteristicRead(data) + } +} diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 2a9cdb832d..093af2f3ab 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -3091,5 +3091,9 @@ Live location sharing 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. Enable location sharing + + + ${app_name} Push to Talk + A service is running to communicate with BLE device