From 9f766a1d36f3391d1985eb366ef3c25efd700b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julia=CC=81n=20Falcionelli?= Date: Thu, 6 May 2021 10:15:46 +0200 Subject: [PATCH 01/11] Base BLE --- app/src/main/AndroidManifest.xml | 5 + .../adapters/BluetoothDevicesAdapter.kt | 38 +++ .../android/bluetooth/BleExceptions.kt | 12 + .../android/bluetooth/BleOperation.kt | 77 +++++ .../android/bluetooth/BleOperationType.kt | 16 + .../android/bluetooth/BluetoothConstants.kt | 5 + .../android/bluetooth/BluetoothDeviceExt.kt | 48 +++ .../bluetooth/BluetoothDeviceManager.kt | 3 + .../android/bluetooth/BluetoothDeviceState.kt | 14 + .../android/bluetooth/BluetoothManager.kt | 48 +++ .../android/bluetooth/BluetoothManagerImpl.kt | 288 ++++++++++++++++++ .../android/bluetooth/BluetoothState.kt | 14 + .../rocketinsights/android/di/KoinUtils.kt | 13 +- .../android/ui/BluetoothFragment.kt | 57 ++++ .../rocketinsights/android/ui/MainFragment.kt | 6 +- .../android/viewmodels/BluetoothViewModel.kt | 168 ++++++++++ .../viewmodels/PermissionsViewModel.kt | 1 - .../main/res/layout/fragment_bluetooth.xml | 121 ++++++++ .../res/layout/list_item_bluetooth_device.xml | 28 ++ app/src/main/res/menu/main_menu.xml | 3 + app/src/main/res/values/strings.xml | 5 + 21 files changed, 967 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/rocketinsights/android/adapters/BluetoothDevicesAdapter.kt create mode 100644 app/src/main/java/com/rocketinsights/android/bluetooth/BleExceptions.kt create mode 100644 app/src/main/java/com/rocketinsights/android/bluetooth/BleOperation.kt create mode 100644 app/src/main/java/com/rocketinsights/android/bluetooth/BleOperationType.kt create mode 100644 app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothConstants.kt create mode 100644 app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothDeviceExt.kt create mode 100644 app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothDeviceManager.kt create mode 100644 app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothDeviceState.kt create mode 100644 app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothManager.kt create mode 100644 app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothManagerImpl.kt create mode 100644 app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothState.kt create mode 100644 app/src/main/java/com/rocketinsights/android/ui/BluetoothFragment.kt create mode 100644 app/src/main/java/com/rocketinsights/android/viewmodels/BluetoothViewModel.kt create mode 100644 app/src/main/res/layout/fragment_bluetooth.xml create mode 100644 app/src/main/res/layout/list_item_bluetooth_device.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cca0799..6a233c7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,11 @@ + + + + + diff --git a/app/src/main/java/com/rocketinsights/android/adapters/BluetoothDevicesAdapter.kt b/app/src/main/java/com/rocketinsights/android/adapters/BluetoothDevicesAdapter.kt new file mode 100644 index 0000000..b99f10b --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/adapters/BluetoothDevicesAdapter.kt @@ -0,0 +1,38 @@ +package com.rocketinsights.android.adapters + +import android.bluetooth.BluetoothDevice +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.rocketinsights.android.databinding.ListItemMessageBinding +import com.rocketinsights.android.extensions.viewBinding + +class BluetoothDevicesAdapter : + ListAdapter(BluetoothDeviceDiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = + ViewHolder(parent.viewBinding(ListItemMessageBinding::inflate)) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + class ViewHolder(private val binding: ListItemMessageBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(item: BluetoothDevice) { + /* binding.textMessage.text = item.text + binding.textTime.text = DateUtils.formatDateTime(binding.root.context, item.timestamp, FORMAT_SHOW_TIME)*/ + } + } + + private class BluetoothDeviceDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: BluetoothDevice, newItem: BluetoothDevice): Boolean = + oldItem.address == newItem.address + + override fun areContentsTheSame(oldItem: BluetoothDevice, newItem: BluetoothDevice): Boolean = + oldItem == newItem + } +} diff --git a/app/src/main/java/com/rocketinsights/android/bluetooth/BleExceptions.kt b/app/src/main/java/com/rocketinsights/android/bluetooth/BleExceptions.kt new file mode 100644 index 0000000..10738be --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/bluetooth/BleExceptions.kt @@ -0,0 +1,12 @@ +package com.rocketinsights.android.bluetooth + +sealed class BleException(message: String?) : Exception(message) { + object BluetoothDeviceUnpairedException : BleException("The requested device got unpaired.") + object BluetoothNotEnabledException : BleException("Bluetooth is Off.") + object BluetoothAccessNotGrantedException : BleException("Bluetooth access not granted.") + object BluetoothNotSupportedException : BleException("The current device doesn't support Bluetooth.") + object PairingToBTDeviceException : BleException( + "Couldn't pair to the selected Bluetooth device. Check if it is in pairing " + + "mode and in the appropriate range." + ) +} diff --git a/app/src/main/java/com/rocketinsights/android/bluetooth/BleOperation.kt b/app/src/main/java/com/rocketinsights/android/bluetooth/BleOperation.kt new file mode 100644 index 0000000..7e1e167 --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/bluetooth/BleOperation.kt @@ -0,0 +1,77 @@ +package com.rocketinsights.android.bluetooth + +import com.rocketinsights.android.bluetooth.BleOperationType.Companion.TYPE_READ +import com.rocketinsights.android.bluetooth.BleOperationType.Companion.TYPE_SUBSCRIBE +import com.rocketinsights.android.bluetooth.BleOperationType.Companion.TYPE_WRITE +import java.util.UUID + +data class BleOperation private constructor( + // Identify temporarily the BLE Operation until we get the final Characteristic Instance ID. + val id: Int, + val service: UUID, + val charUuid: UUID, + // Only used for characteristic write + val data: ByteArray?, + // Only used for characteristic notification subscription + val enable: Boolean, + @BleOperationType + val type: Int, + val highPriority: Boolean +) { + + companion object { + private var sTemporaryId = 0 + + private fun getTemporaryId() = sTemporaryId++ + + fun newWriteOperation( + service: UUID, + charUuid: UUID, + data: ByteArray?, + highPriority: Boolean + ): BleOperation { + return BleOperation( + getTemporaryId(), + service, + charUuid, + data, + false, + TYPE_WRITE, + highPriority + ) + } + + fun newReadOperation( + service: UUID, + charUuid: UUID, + highPriority: Boolean + ): BleOperation { + return BleOperation( + getTemporaryId(), + service, + charUuid, + null, + false, + TYPE_READ, + highPriority + ) + } + + fun newSubscribeOperation( + service: UUID, + charUuid: UUID, + enable: Boolean, + highPriority: Boolean + ): BleOperation { + return BleOperation( + getTemporaryId(), + service, + charUuid, + null, + enable, + TYPE_SUBSCRIBE, + highPriority + ) + } + } +} diff --git a/app/src/main/java/com/rocketinsights/android/bluetooth/BleOperationType.kt b/app/src/main/java/com/rocketinsights/android/bluetooth/BleOperationType.kt new file mode 100644 index 0000000..a174a42 --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/bluetooth/BleOperationType.kt @@ -0,0 +1,16 @@ +package com.rocketinsights.android.bluetooth + +import androidx.annotation.IntDef +import com.rocketinsights.android.bluetooth.BleOperationType.Companion.TYPE_READ +import com.rocketinsights.android.bluetooth.BleOperationType.Companion.TYPE_SUBSCRIBE +import com.rocketinsights.android.bluetooth.BleOperationType.Companion.TYPE_WRITE + +@Retention(AnnotationRetention.SOURCE) +@IntDef(TYPE_SUBSCRIBE, TYPE_READ, TYPE_WRITE) +annotation class BleOperationType { + companion object { + const val TYPE_SUBSCRIBE = 0 + const val TYPE_READ = 1 + const val TYPE_WRITE = 2 + } +} diff --git a/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothConstants.kt b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothConstants.kt new file mode 100644 index 0000000..b11c647 --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothConstants.kt @@ -0,0 +1,5 @@ +package com.rocketinsights.android.bluetooth + +object BluetoothConstants { + const val GET_FIRST_8_BITS = 0xFF +} diff --git a/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothDeviceExt.kt b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothDeviceExt.kt new file mode 100644 index 0000000..7b33d86 --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothDeviceExt.kt @@ -0,0 +1,48 @@ +package com.rocketinsights.android.bluetooth + +import android.bluetooth.BluetoothDevice +import timber.log.Timber + +private const val TAG = "BluetoothDevice" + +private const val LOG_SEPARATOR = "===========================================" +private const val LOG_NEW_LINE = "\n" +private const val LOG_ITEM_SEPARATOR = "----------" + +fun BluetoothDevice.removeBond() { + try { + javaClass.getMethod("removeBond").invoke(this) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Removing bond has been failed. ${e.message}") + } +} + +fun BluetoothDevice.dump() { + StringBuilder().apply { + append(LOG_SEPARATOR) + append("Dumping Bluetooth Device Data") + append(LOG_NEW_LINE) + append("Mac Address: $address") + append(LOG_NEW_LINE) + append("Name: $name") + append(LOG_NEW_LINE) + append(LOG_ITEM_SEPARATOR) + append(LOG_NEW_LINE) + append("List Of Services: ") + append(LOG_NEW_LINE) + append(LOG_NEW_LINE) + + uuids?.let { + for (parcelUuid in it) { + append("Service: " + parcelUuid.uuid.toString()) + append(LOG_NEW_LINE) + } + } + + append(LOG_NEW_LINE) + append(LOG_SEPARATOR) + append(LOG_NEW_LINE) + + Timber.tag(TAG).d(toString()) + } +} diff --git a/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothDeviceManager.kt b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothDeviceManager.kt new file mode 100644 index 0000000..5740e1d --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothDeviceManager.kt @@ -0,0 +1,3 @@ +package com.rocketinsights.android.bluetooth + +interface BluetoothDeviceManager diff --git a/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothDeviceState.kt b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothDeviceState.kt new file mode 100644 index 0000000..d8cbdb5 --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothDeviceState.kt @@ -0,0 +1,14 @@ +package com.rocketinsights.android.bluetooth + +import androidx.annotation.IntDef +import com.rocketinsights.android.bluetooth.BluetoothDeviceState.Companion.CONNECTED +import com.rocketinsights.android.bluetooth.BluetoothDeviceState.Companion.DISCONNECTED + +@Retention(AnnotationRetention.SOURCE) +@IntDef(CONNECTED, DISCONNECTED) +annotation class BluetoothDeviceState { + companion object { + const val CONNECTED = 0 + const val DISCONNECTED = 1 + } +} diff --git a/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothManager.kt b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothManager.kt new file mode 100644 index 0000000..dad09c3 --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothManager.kt @@ -0,0 +1,48 @@ +package com.rocketinsights.android.bluetooth + +import android.Manifest +import android.bluetooth.BluetoothDevice +import kotlinx.coroutines.flow.Flow + +/** + * BLE Generic Operations: + * - Turn On / Off + * - Discover Devices + * - Pairing / Unpairing + * + * Once you got your device paired you must use a BluetoothDeviceManager Instance to perform I/O + * events. + */ +interface BluetoothManager { + companion object { + val BLUETOOTH_PERMISSIONS = arrayOf( + Manifest.permission.BLUETOOTH, + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION + ) + } + + // Status + suspend fun bluetoothStatus(): Int + fun observeBluetoothStatus(): Flow + + // Turn On / Off + suspend fun turnOn() + suspend fun turnOff() + + // Devices + fun pairedDevices(): Set + + // Discovery + suspend fun startDiscovery() + suspend fun stopDiscovery() + fun observeBluetoothDiscovery(): Flow + + // Actions + suspend fun pairDevice(bluetoothDevice: BluetoothDevice?) + suspend fun unpair(deviceMacAddress: String?) + suspend fun discoverAndPairDevice(deviceMacAddress: String?) + + // Stop / Clear states + fun stop() +} diff --git a/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothManagerImpl.kt b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothManagerImpl.kt new file mode 100644 index 0000000..f1ddc6b --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothManagerImpl.kt @@ -0,0 +1,288 @@ +package com.rocketinsights.android.bluetooth + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import com.rocketinsights.android.coroutines.DispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +class BluetoothManagerImpl( + private val context: Context, + private val dispatcher: DispatcherProvider +) : BluetoothManager { + companion object { + const val TAG: String = "BluetoothManager" + } + + init { + registerToBluetoothChanges() + } + + private val scope = CoroutineScope(dispatcher.io()) + + private var isDiscoveringReceiverRegistered = false + private var isPairingReceiverRegistered = false + + // Forever Observers + private val bluetoothStatusObserver = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1 + ) + private val bluetoothStatusReceiver: BroadcastReceiver? = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + val action = intent?.action ?: return + + if (BluetoothAdapter.ACTION_STATE_CHANGED == action) { + val state = intent.getIntExtra( + BluetoothAdapter.EXTRA_STATE, + BluetoothAdapter.ERROR + ) + when (state) { + BluetoothAdapter.STATE_OFF -> bluetoothStatusObserver.tryEmit(BluetoothState.BLUETOOTH_OFF) + BluetoothAdapter.STATE_TURNING_OFF -> { + } + BluetoothAdapter.STATE_ON -> bluetoothStatusObserver.tryEmit(BluetoothState.BLUETOOTH_ON) + BluetoothAdapter.STATE_TURNING_ON -> { + } + } + } + } + } + private val discoveryObserver = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1 + ) + private val discoveryReceiver: BroadcastReceiver? = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + val action = intent?.action ?: return + // When discovery finds a device + if (BluetoothDevice.ACTION_FOUND == action) { + // Get the BluetoothDevice object from the Intent + val device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + device?.let { + discoveryObserver.tryEmit(it) + } + } + } + } + + sealed class PairingProcessResult { + class Success(val device: BluetoothDevice) : PairingProcessResult() + class Error(val error: Exception) : PairingProcessResult() + } + + // One Time Observers + private var pairingObserver = MutableStateFlow(null) + private val pairingReceiver: BroadcastReceiver? = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + val action = intent?.action ?: return + + // When discovery finds a device + if (BluetoothDevice.ACTION_BOND_STATE_CHANGED == action) { + val state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1) + if (state == BluetoothDevice.BOND_BONDING) { + // Bonding process is still working + // Essentially this means that the Confirmation Dialog is still visible + return // Do Nothing + } + if (state == BluetoothDevice.BOND_BONDED) { + // bonding process was successful + // also means that the user pressed OK on the Dialog + + // Get the BluetoothDevice object from the Intent + intent.getParcelableExtra( + BluetoothDevice.EXTRA_DEVICE + )?.let { + pairingObserver.tryEmit(PairingProcessResult.Success(it)) + } + + return + } + + // Paired device not found means error/cancel during pairing process + pairingObserver.tryEmit(PairingProcessResult.Error(BleException.PairingToBTDeviceException)) + } + } + } + + // ======== Start Bluetooth Status Related ======== + + override suspend fun bluetoothStatus(): Int { + return if (getBluetoothAdapter().isEnabled) { + BluetoothState.BLUETOOTH_ON + } else { + BluetoothState.BLUETOOTH_OFF + } + } + + override fun observeBluetoothStatus(): Flow = bluetoothStatusObserver + + override suspend fun turnOn(): Unit = suspendCoroutine { cont -> + val btAdapter = getBluetoothAdapter() + if (!btAdapter.isEnabled) { + scope.launch { + // Wait for connection, turn on can take a while. + bluetoothStatusObserver + .filter { status -> status == BluetoothState.BLUETOOTH_ON } + .collect { + cont.resume(Unit) + } + } + + // Works only with ADMIN BT permission. + btAdapter.enable() + } else { + // BT already enabled + cont.resume(Unit) + } + } + + override suspend fun turnOff() { + getBluetoothAdapter().apply { + if (isEnabled) { + disable() + } + } + } + + // ======== End Bluetooth Status Related ======== + + // ======== Start Pairing Related ======== + + override fun pairedDevices() = getBluetoothPairedDevices() + + override suspend fun pairDevice(bluetoothDevice: BluetoothDevice?): Unit = suspendCoroutine { cont -> + // Check if device is already paired + if (getPairedDevice(bluetoothDevice!!.address) != null) { + cont.resume(Unit) + return@suspendCoroutine + } + + scope.launch { + pairingObserver = MutableStateFlow(null) + pairingObserver + .collect { + when (it) { + is PairingProcessResult.Success -> { + // We could double-check address here + // it.device == bluetoothDevice.address + cont.resume(Unit) + } + is PairingProcessResult.Error -> { + cont.resumeWithException(it.error) + } + } + } + } + + registerToBTPairingReceiver() + bluetoothDevice.createBond() + } + + override suspend fun startDiscovery() { + getBluetoothAdapter().apply { + if (!isDiscovering) { + startDiscovery() + } + } + registerToBTDiscoveryReceiver() + } + + override suspend fun stopDiscovery() { + unregisterToBTDiscoveryReceiver() + getBluetoothAdapter().cancelDiscovery() + } + + // ======== End Pairing Related ======== + + // ======== Start Discovery Related ======== + override fun observeBluetoothDiscovery(): Flow = discoveryObserver + + override suspend fun unpair(deviceMacAddress: String?) { + getPairedDevice(deviceMacAddress)?.removeBond() + } + + override suspend fun discoverAndPairDevice(deviceMacAddress: String?) { + stopDiscovery() + startDiscovery() + + val device = observeBluetoothDiscovery() + .filter { bluetoothDevice -> + bluetoothDevice.address == deviceMacAddress + } + .firstOrNull() ?: return + + pairDevice(device) + stopDiscovery() + } + + override fun stop() { + getBluetoothAdapter().cancelDiscovery() + unregisterToBTDiscoveryReceiver() + unregisterToBTPairingReceiver() + } + + private fun registerToBluetoothChanges() { + val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) + context.registerReceiver(bluetoothStatusReceiver, filter) + } + + private fun registerToBTPairingReceiver() { + if (!isPairingReceiverRegistered) { + val filter = IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED) + context.registerReceiver(pairingReceiver, filter) + isPairingReceiverRegistered = true + } + } + + // ======== End Discovery Related ======== + + private fun unregisterToBTPairingReceiver() { + if (isPairingReceiverRegistered) { + context.unregisterReceiver(pairingReceiver) + isPairingReceiverRegistered = false + } + } + + private fun registerToBTDiscoveryReceiver() { + val filter = IntentFilter(BluetoothDevice.ACTION_FOUND) + context.registerReceiver(discoveryReceiver, filter) + isDiscoveringReceiverRegistered = true + } + + private fun unregisterToBTDiscoveryReceiver() { + if (isDiscoveringReceiverRegistered) { + context.unregisterReceiver(discoveryReceiver) + isDiscoveringReceiverRegistered = false + } + } + + private fun getBluetoothPairedDevices(): Set = + getBluetoothAdapter().bondedDevices + + protected fun getBluetoothAdapter(): BluetoothAdapter { + return BluetoothAdapter.getDefaultAdapter() + ?: throw BleException.BluetoothNotSupportedException // Device does not support Bluetooth + } + + private fun isDevicePaired(mac: String?) = getPairedDevice(mac) != null + + private fun getPairedDevice(mac: String?): BluetoothDevice? { + return getBluetoothPairedDevices().firstOrNull { bluetoothDevice -> + bluetoothDevice.address == mac + } + } +} diff --git a/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothState.kt b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothState.kt new file mode 100644 index 0000000..9cf5805 --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothState.kt @@ -0,0 +1,14 @@ +package com.rocketinsights.android.bluetooth + +import androidx.annotation.IntDef +import com.rocketinsights.android.bluetooth.BluetoothState.Companion.BLUETOOTH_OFF +import com.rocketinsights.android.bluetooth.BluetoothState.Companion.BLUETOOTH_ON + +@Retention(AnnotationRetention.SOURCE) +@IntDef(BLUETOOTH_ON, BLUETOOTH_OFF) +annotation class BluetoothState { + companion object { + const val BLUETOOTH_ON = 0 + const val BLUETOOTH_OFF = 1 + } +} diff --git a/app/src/main/java/com/rocketinsights/android/di/KoinUtils.kt b/app/src/main/java/com/rocketinsights/android/di/KoinUtils.kt index 82775c9..7e77c66 100644 --- a/app/src/main/java/com/rocketinsights/android/di/KoinUtils.kt +++ b/app/src/main/java/com/rocketinsights/android/di/KoinUtils.kt @@ -12,6 +12,8 @@ import com.rocketinsights.android.auth.AuthManager import com.rocketinsights.android.auth.FirebaseAuthManager import com.rocketinsights.android.auth.SessionStorage import com.rocketinsights.android.auth.SessionWatcher +import com.rocketinsights.android.bluetooth.BluetoothManager +import com.rocketinsights.android.bluetooth.BluetoothManagerImpl import com.rocketinsights.android.coroutines.DispatcherProvider import com.rocketinsights.android.coroutines.DispatcherProviderImpl import com.rocketinsights.android.db.Database @@ -33,6 +35,7 @@ import com.rocketinsights.android.repos.AuthRepository import com.rocketinsights.android.repos.MessageRepository import com.rocketinsights.android.ui.MainActivity import com.rocketinsights.android.ui.ParentScrollProvider +import com.rocketinsights.android.viewmodels.BluetoothViewModel import com.rocketinsights.android.viewmodels.CalendarViewModel import com.rocketinsights.android.viewmodels.ConnectivityViewModel import com.rocketinsights.android.viewmodels.LocationViewModel @@ -73,7 +76,8 @@ fun Application.initKoin() { authModule(), viewModelsModule(), viewInteractorsModule(), - workModule() + workModule(), + bluetoothModule(), ) ) } @@ -151,6 +155,7 @@ private fun viewModelsModule() = module { viewModel { PhotoViewModel() } viewModel { LocationViewModel(get(), get()) } viewModel { CalendarViewModel(get()) } + viewModel { BluetoothViewModel(get()) } } private fun viewInteractorsModule() = module { @@ -166,3 +171,9 @@ private fun workModule() = module { single { WorkImpl(get()) } single { MessagesUpdateSchedulerImpl(get(), get(), get()) } } + +private fun bluetoothModule() = module { + single { + BluetoothManagerImpl(get(), get()) + } +} diff --git a/app/src/main/java/com/rocketinsights/android/ui/BluetoothFragment.kt b/app/src/main/java/com/rocketinsights/android/ui/BluetoothFragment.kt new file mode 100644 index 0000000..afd0be5 --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/ui/BluetoothFragment.kt @@ -0,0 +1,57 @@ +package com.rocketinsights.android.ui + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.lifecycle.observe +import androidx.navigation.fragment.findNavController +import com.rocketinsights.android.R +import com.rocketinsights.android.extensions.showToast +import com.rocketinsights.android.viewmodels.BluetoothViewModel +import com.rocketinsights.android.viewmodels.PermissionsResult +import com.rocketinsights.android.viewmodels.PermissionsViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel + +class BluetoothFragment : Fragment(R.layout.fragment_maps) { + private val permissionsViewModel: PermissionsViewModel by viewModel() + private val bluetoothViewModel: BluetoothViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + permissionsViewModel.requestPermissions( + this, + *BluetoothViewModel.BLUETOOTH_PERMISSIONS + ) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initUI() + setupObservers() + } + + override fun onResume() { + super.onResume() + } + + override fun onDestroyView() { + super.onDestroyView() + } + + private fun initUI() { + } + + private fun setupObservers() { + permissionsViewModel.permissionsResult.observe(viewLifecycleOwner) { + it.getContentIfNotHandled()?.let { permissionResult -> + if (permissionResult is PermissionsResult.PermissionsError) { + requireContext().showToast( + getString(R.string.bluetooth_permission_not_granted) + ) + findNavController().popBackStack() + } + } + } + } +} diff --git a/app/src/main/java/com/rocketinsights/android/ui/MainFragment.kt b/app/src/main/java/com/rocketinsights/android/ui/MainFragment.kt index 2601439..8c262c4 100644 --- a/app/src/main/java/com/rocketinsights/android/ui/MainFragment.kt +++ b/app/src/main/java/com/rocketinsights/android/ui/MainFragment.kt @@ -76,7 +76,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { inflater.inflate(R.menu.main_menu, menu) loginMenuItem = menu.findItem(R.id.menu_login) logoutMenuItem = menu.findItem(R.id.menu_logout) - photoMenuItem = menu.findItem(R.id.menu_logout) + photoMenuItem = menu.findItem(R.id.photo_fragment) hideLoginItems() setPhotoItemVisibility() observeUserLoginStatus() @@ -101,6 +101,10 @@ class MainFragment : Fragment(R.layout.fragment_main) { setFadeThroughTransition() item.onNavDestinationSelected(findNavController()) } + R.id.bluetooth_fragment -> { + setFadeThroughTransition() + item.onNavDestinationSelected(findNavController()) + } R.id.menu_login -> { authManager.launchSignInFlow() true diff --git a/app/src/main/java/com/rocketinsights/android/viewmodels/BluetoothViewModel.kt b/app/src/main/java/com/rocketinsights/android/viewmodels/BluetoothViewModel.kt new file mode 100644 index 0000000..147f9c5 --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/viewmodels/BluetoothViewModel.kt @@ -0,0 +1,168 @@ +package com.rocketinsights.android.viewmodels + +import android.bluetooth.BluetoothDevice +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import com.rocketinsights.android.bluetooth.BleException +import com.rocketinsights.android.bluetooth.BluetoothManager +import com.rocketinsights.android.viewmodels.event.Event +import kotlinx.coroutines.launch + +class BluetoothViewModel( + private val bluetoothManager: BluetoothManager +) : ViewModel() { + + companion object { + val BLUETOOTH_PERMISSIONS = BluetoothManager.BLUETOOTH_PERMISSIONS + } + + private val _bluetoothActionState = MutableLiveData>() + val bluetoothActionState: LiveData> = _bluetoothActionState + + val bluetoothState: LiveData = bluetoothManager.observeBluetoothStatus().asLiveData() + + val nearDevices: LiveData = bluetoothManager.observeBluetoothDiscovery().asLiveData() + + fun turnOnBluetooth() { + viewModelScope.launch { + try { + _bluetoothActionState.value = Event(BluetoothActionState.TurnOn.Loading) + bluetoothManager.turnOn() + _bluetoothActionState.value = Event(BluetoothActionState.TurnOn.Done) + } catch (e: Exception) { + handlerError(e) { + _bluetoothActionState.value = Event(BluetoothActionState.TurnOn.Error(e)) + } + } + } + } + + fun turnOffBluetooth() { + viewModelScope.launch { + try { + _bluetoothActionState.value = Event(BluetoothActionState.TurnOff.Loading) + bluetoothManager.turnOff() + _bluetoothActionState.value = Event(BluetoothActionState.TurnOff.Done) + } catch (e: Exception) { + handlerError(e) { + _bluetoothActionState.value = Event(BluetoothActionState.TurnOff.Error(e)) + } + } + } + } + + fun startNearBluetoothDevicesSearch() { + viewModelScope.launch { + try { + _bluetoothActionState.value = Event(BluetoothActionState.Discovery.Loading) + bluetoothManager.startDiscovery() + _bluetoothActionState.value = Event(BluetoothActionState.Discovery.Done) + } catch (e: Exception) { + handlerError(e) { + _bluetoothActionState.value = Event(BluetoothActionState.Discovery.Error(e)) + } + } + } + } + + fun stopNearBluetoothDevicesSearch() { + viewModelScope.launch { + try { + _bluetoothActionState.value = Event(BluetoothActionState.Discovery.Loading) + bluetoothManager.stopDiscovery() + _bluetoothActionState.value = Event(BluetoothActionState.Discovery.Done) + } catch (e: Exception) { + handlerError(e) { + _bluetoothActionState.value = Event(BluetoothActionState.Discovery.Error(e)) + } + } + } + } + + fun pairDevice(bluetoothDevice: BluetoothDevice) { + viewModelScope.launch { + try { + _bluetoothActionState.value = Event(BluetoothActionState.Pair.Loading) + bluetoothManager.pairDevice(bluetoothDevice) + _bluetoothActionState.value = Event(BluetoothActionState.Pair.Done) + } catch (e: Exception) { + handlerError(e) { + _bluetoothActionState.value = Event(BluetoothActionState.Pair.Error(e)) + } + } + } + } + + fun unpairDevice(bluetoothDevice: BluetoothDevice) { + viewModelScope.launch { + try { + _bluetoothActionState.value = Event(BluetoothActionState.UnPair.Loading) + bluetoothManager.pairDevice(bluetoothDevice) + _bluetoothActionState.value = Event(BluetoothActionState.UnPair.Done) + } catch (e: Exception) { + handlerError(e) { + _bluetoothActionState.value = Event(BluetoothActionState.UnPair.Error(e)) + } + } + } + } + + private fun handlerError(error: Exception, fallback: () -> Unit) { + when (error) { + is BleException.BluetoothNotSupportedException -> { + _bluetoothActionState.value = Event(BluetoothActionState.Unsupported) + } + is BleException.BluetoothNotEnabledException -> { + _bluetoothActionState.value = Event(BluetoothActionState.ServicesOff) + } + is BleException.BluetoothAccessNotGrantedException -> { + _bluetoothActionState.value = Event(BluetoothActionState.PermissionsNeed) + } + else -> fallback.invoke() + } + } + + override fun onCleared() { + bluetoothManager.stop() + super.onCleared() + } +} + +sealed class BluetoothActionState { + object Unsupported : BluetoothActionState() + object ServicesOff : BluetoothActionState() + object PermissionsNeed : BluetoothActionState() + + sealed class TurnOn : BluetoothActionState() { + object Loading : TurnOn() + object Done : TurnOn() + class Error(error: Exception) : TurnOn() + } + + sealed class TurnOff : BluetoothActionState() { + object Loading : TurnOff() + object Done : TurnOff() + class Error(error: Exception) : TurnOff() + } + + sealed class Pair : BluetoothActionState() { + object Loading : Pair() + object Done : Pair() + class Error(error: Exception) : Pair() + } + + sealed class UnPair : BluetoothActionState() { + object Loading : UnPair() + object Done : UnPair() + class Error(error: Exception) : UnPair() + } + + sealed class Discovery : BluetoothActionState() { + object Loading : Discovery() + object Done : Discovery() + class Error(error: Exception) : Discovery() + } +} diff --git a/app/src/main/java/com/rocketinsights/android/viewmodels/PermissionsViewModel.kt b/app/src/main/java/com/rocketinsights/android/viewmodels/PermissionsViewModel.kt index 55d373d..c5dc951 100644 --- a/app/src/main/java/com/rocketinsights/android/viewmodels/PermissionsViewModel.kt +++ b/app/src/main/java/com/rocketinsights/android/viewmodels/PermissionsViewModel.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.launch class PermissionsViewModel( private val manager: PermissionsManager ) : ViewModel() { - private val _permissionsResult = MutableLiveData>() val permissionsResult: LiveData> = _permissionsResult diff --git a/app/src/main/res/layout/fragment_bluetooth.xml b/app/src/main/res/layout/fragment_bluetooth.xml new file mode 100644 index 0000000..7a55c8c --- /dev/null +++ b/app/src/main/res/layout/fragment_bluetooth.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +