diff --git a/.idea/navEditor.xml b/.idea/navEditor.xml index 5e1fcfb..ba46774 100644 --- a/.idea/navEditor.xml +++ b/.idea/navEditor.xml @@ -5,7 +5,229 @@ - + + + + + + + + + + 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/bluetooth/BleException.kt b/app/src/main/java/com/rocketinsights/android/bluetooth/BleException.kt new file mode 100644 index 0000000..10738be --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/bluetooth/BleException.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/BluetoothDeviceExt.kt b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothDeviceExt.kt new file mode 100644 index 0000000..686a24a --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothDeviceExt.kt @@ -0,0 +1,51 @@ +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 = "----------" + +/** + * There is not a direct way to remove bond, we must use reflection for it. + */ +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/BluetoothManager.kt b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothManager.kt new file mode 100644 index 0000000..8a23280 --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothManager.kt @@ -0,0 +1,46 @@ +package com.rocketinsights.android.bluetooth + +import android.Manifest +import android.bluetooth.BluetoothDevice +import kotlinx.coroutines.flow.Flow + +/** + * Bluetooth Manager allows us to perform the basic bluetooth actions: turn on, turn off, + * search for devices, pair and un-pair. + * + * The sending of operations (read, write and subscribe) to a BLE device is pending. + */ +interface BluetoothManager { + companion object { + val BLUETOOTH_PERMISSIONS = arrayOf( + Manifest.permission.BLUETOOTH, + Manifest.permission.BLUETOOTH_ADMIN, + 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..419c58a --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/bluetooth/BluetoothManagerImpl.kt @@ -0,0 +1,278 @@ +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 + + private val bluetoothStatusObserver = MutableStateFlow(BluetoothState.BLUETOOTH_ON) + 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() + } + + 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)) + } + } + } + + // region BT Status + 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() + } + } + } + // endregion BT Status + + // region Pairing + 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 unpair(deviceMacAddress: String) { + getPairedDevice(deviceMacAddress)?.removeBond() + } + // endregion Pairing + + // region Discovery + override suspend fun startDiscovery() { + getBluetoothAdapter().apply { + if (!isDiscovering) { + startDiscovery() + } + } + registerToBTDiscoveryReceiver() + } + + override suspend fun stopDiscovery() { + unregisterToBTDiscoveryReceiver() + getBluetoothAdapter().cancelDiscovery() + } + + override fun observeBluetoothDiscovery(): Flow = discoveryObserver + + override suspend fun discoverAndPairDevice(deviceMacAddress: String) { + stopDiscovery() + startDiscovery() + + val device = observeBluetoothDiscovery() + .filter { bluetoothDevice -> + bluetoothDevice.address == deviceMacAddress + } + .firstOrNull() ?: return + + pairDevice(device) + stopDiscovery() + } + // endregion Discovery + + 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 + } + } + + 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 + + private 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/extensions/LayoutProgressExt.kt b/app/src/main/java/com/rocketinsights/android/extensions/LayoutProgressExt.kt index 3193cd5..253ca86 100644 --- a/app/src/main/java/com/rocketinsights/android/extensions/LayoutProgressExt.kt +++ b/app/src/main/java/com/rocketinsights/android/extensions/LayoutProgressExt.kt @@ -6,6 +6,6 @@ fun LayoutProgressBinding.show() { root.show() } -fun LayoutProgressBinding.hide() { - root.hide() +fun LayoutProgressBinding.remove() { + root.remove() } 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..d46c471 --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/ui/BluetoothFragment.kt @@ -0,0 +1,198 @@ +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.bluetooth.BluetoothState.Companion.BLUETOOTH_ON +import com.rocketinsights.android.databinding.FragmentBluetoothBinding +import com.rocketinsights.android.extensions.disable +import com.rocketinsights.android.extensions.enable +import com.rocketinsights.android.extensions.remove +import com.rocketinsights.android.extensions.setupActionBar +import com.rocketinsights.android.extensions.show +import com.rocketinsights.android.extensions.showToast +import com.rocketinsights.android.extensions.viewBinding +import com.rocketinsights.android.ui.adapters.BluetoothDevicesAdapter +import com.rocketinsights.android.ui.components.dialog +import com.rocketinsights.android.viewmodels.BluetoothActionState +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_bluetooth) { + private val binding by viewBinding(FragmentBluetoothBinding::bind) + private val permissionsViewModel: PermissionsViewModel by viewModel() + private val bluetoothViewModel: BluetoothViewModel by viewModel() + + private lateinit var pairedDevicesAdapter: BluetoothDevicesAdapter + private lateinit var nearDevicesAdapter: BluetoothDevicesAdapter + + 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) + setupActionBar(binding.toolbar) + initUI() + setupPairedDevicesList() + setupNearDevicesList() + setupObservers() + } + + private fun initUI() { + binding.bluetoothStatusSwitch.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + bluetoothViewModel.turnOnBluetooth() + } else { + bluetoothViewModel.turnOffBluetooth() + } + } + + binding.bluetoothDiscoverDevicesButton.setOnClickListener { + bluetoothViewModel.startNearBluetoothDevicesSearch() + } + + binding.bluetoothStopDiscoverDevicesButton.setOnClickListener { + bluetoothViewModel.stopNearBluetoothDevicesSearch() + } + } + + private fun setupPairedDevicesList() { + pairedDevicesAdapter = BluetoothDevicesAdapter { btDevice -> + dialog { + title = btDevice.name ?: getString(R.string.bluetooth_unnamed) + content = getString(R.string.bluetooth_mac_address, btDevice.address) + positiveTextRes = R.string.bluetooth_unpair + positiveAction = { + bluetoothViewModel.unpairDevice(btDevice) + } + } + } + + binding.bluetoothPairedDevicesList.adapter = pairedDevicesAdapter + } + + private fun setupNearDevicesList() { + nearDevicesAdapter = BluetoothDevicesAdapter { btDevice -> + dialog { + title = btDevice.name ?: getString(R.string.bluetooth_unnamed) + content = getString(R.string.bluetooth_mac_address, btDevice.address) + positiveTextRes = R.string.bluetooth_pair + positiveAction = { + bluetoothViewModel.pairDevice(btDevice) + } + } + } + + binding.bluetoothNearDevicesList.adapter = nearDevicesAdapter + } + + 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() + } + } + } + + bluetoothViewModel.bluetoothActionState.observe(viewLifecycleOwner) { + it.getContentIfNotHandled().let { btActionState -> + when (btActionState) { + is BluetoothActionState.Unsupported -> { + requireContext().showToast(getString(R.string.bluetooth_not_supported)) + } + is BluetoothActionState.ServicesOff -> { + requireContext().showToast(getString(R.string.bluetooth_off)) + } + is BluetoothActionState.PermissionsNeed -> { + requireContext().showToast(getString(R.string.bluetooth_permissions_denied)) + } + is BluetoothActionState.TurnOff.Loading, + is BluetoothActionState.TurnOn.Loading -> { + binding.bluetoothStatusSwitch.disable() + } + is BluetoothActionState.TurnOff.Error, + is BluetoothActionState.TurnOff.Done, + is BluetoothActionState.TurnOn.Error, + is BluetoothActionState.TurnOn.Done -> { + binding.bluetoothStatusSwitch.enable() + } + is BluetoothActionState.UnPair.Loading, + is BluetoothActionState.Pair.Loading -> { + binding.progress.show() + } + is BluetoothActionState.UnPair.Error, + is BluetoothActionState.Pair.Error -> { + binding.progress.remove() + } + is BluetoothActionState.Pair.Done -> { + requireContext().showToast(getString(R.string.bluetooth_pair_done)) + binding.progress.remove() + } + is BluetoothActionState.UnPair.Done -> { + requireContext().showToast(getString(R.string.bluetooth_unpair_done)) + binding.progress.remove() + } + is BluetoothActionState.DiscoveryStart.Loading -> { + binding.progress.show() + } + is BluetoothActionState.DiscoveryStart.Error -> { + binding.progress.remove() + binding.bluetoothDiscoverDevicesButton.show() + } + is BluetoothActionState.DiscoveryStart.Done -> { + binding.progress.remove() + binding.bluetoothDiscoverDevicesButton.remove() + binding.bluetoothStopDiscoverDevicesButton.show() + } + is BluetoothActionState.DiscoveryStop.Loading -> { + binding.progress.show() + } + is BluetoothActionState.DiscoveryStop.Error -> { + binding.progress.remove() + binding.bluetoothStopDiscoverDevicesButton.show() + } + is BluetoothActionState.DiscoveryStop.Done -> { + binding.progress.remove() + binding.bluetoothStopDiscoverDevicesButton.remove() + binding.bluetoothDiscoverDevicesButton.show() + } + } + } + } + + bluetoothViewModel.bluetoothState.observe(viewLifecycleOwner) { btState -> + binding.bluetoothStatusSwitch.isChecked = btState == BLUETOOTH_ON + } + + bluetoothViewModel.pairedDevices.observe(viewLifecycleOwner) { + pairedDevicesAdapter.submitList(it) + } + + bluetoothViewModel.nearDevices.observe(viewLifecycleOwner) { btDevice -> + val currentList = nearDevicesAdapter.currentList.toMutableList() + // During discovery the same device could appear multiple times + if (currentList.firstOrNull { + it.address == btDevice.address + } == null + ) { + currentList.add(btDevice) + nearDevicesAdapter.submitList(currentList) + } + } + } +} diff --git a/app/src/main/java/com/rocketinsights/android/ui/CalendarFragment.kt b/app/src/main/java/com/rocketinsights/android/ui/CalendarFragment.kt index 2cf544f..e1f3a71 100644 --- a/app/src/main/java/com/rocketinsights/android/ui/CalendarFragment.kt +++ b/app/src/main/java/com/rocketinsights/android/ui/CalendarFragment.kt @@ -9,7 +9,7 @@ import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.RecyclerView import com.rocketinsights.android.R import com.rocketinsights.android.databinding.FragmentCalendarBinding -import com.rocketinsights.android.extensions.hide +import com.rocketinsights.android.extensions.remove import com.rocketinsights.android.extensions.setupActionBar import com.rocketinsights.android.extensions.show import com.rocketinsights.android.extensions.showToast @@ -78,10 +78,10 @@ class CalendarFragment : Fragment(R.layout.fragment_calendar) { when (calendarState) { CalendarState.Loading -> binding.progress.show() CalendarState.Success -> { - binding.progress.hide() + binding.progress.remove() } is CalendarState.Error -> { - binding.progress.hide() + binding.progress.remove() requireContext().showToast( getString(R.string.calendar_error) ) 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 6662b27..e5bb134 100644 --- a/app/src/main/java/com/rocketinsights/android/ui/MainFragment.kt +++ b/app/src/main/java/com/rocketinsights/android/ui/MainFragment.kt @@ -77,7 +77,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() @@ -102,6 +102,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/ui/adapters/BluetoothDevicesAdapter.kt b/app/src/main/java/com/rocketinsights/android/ui/adapters/BluetoothDevicesAdapter.kt new file mode 100644 index 0000000..095287c --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/ui/adapters/BluetoothDevicesAdapter.kt @@ -0,0 +1,46 @@ +package com.rocketinsights.android.ui.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.ListItemBluetoothDeviceBinding +import com.rocketinsights.android.extensions.viewBinding + +class BluetoothDevicesAdapter(private val itemClickListener: (btDevice: BluetoothDevice) -> Unit) : + ListAdapter(BluetoothDeviceDiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = + ViewHolder(parent.viewBinding(ListItemBluetoothDeviceBinding::inflate)) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position), itemClickListener) + } + + class ViewHolder(private val binding: ListItemBluetoothDeviceBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(item: BluetoothDevice, itemClickListener: (btDevice: BluetoothDevice) -> Unit) { + binding.root.setOnClickListener { + itemClickListener.invoke(item) + } + + binding.bluetoothDeviceName.text = if (!item.name.isNullOrBlank()) { + item.name + } else { + "Unnamed" + } + + binding.bluetoothDeviceAddress.text = item.address + } + } + + 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/ui/adapters/MessagesAdapter.kt b/app/src/main/java/com/rocketinsights/android/ui/adapters/MessagesAdapter.kt index a5547c7..e66edc9 100644 --- a/app/src/main/java/com/rocketinsights/android/ui/adapters/MessagesAdapter.kt +++ b/app/src/main/java/com/rocketinsights/android/ui/adapters/MessagesAdapter.kt @@ -29,7 +29,6 @@ class MessagesAdapter : ListAdapter(Message } private class MessageDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Message, newItem: Message): Boolean = oldItem.id == newItem.id 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..ce8503a --- /dev/null +++ b/app/src/main/java/com/rocketinsights/android/viewmodels/BluetoothViewModel.kt @@ -0,0 +1,204 @@ +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 +import timber.log.Timber + +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() + + private val _pairedDevices = MutableLiveData>() + val pairedDevices: LiveData> = _pairedDevices + + val nearDevices: LiveData = bluetoothManager.observeBluetoothDiscovery().asLiveData() + + init { + retrievePairedDevices() + } + + private fun retrievePairedDevices() { + viewModelScope.launch { + try { + _pairedDevices.value = bluetoothManager.pairedDevices().toList() + } catch (e: Exception) { + Timber.w(e) + } + } + } + + 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.DiscoveryStart.Loading) + bluetoothManager.startDiscovery() + _bluetoothActionState.value = Event(BluetoothActionState.DiscoveryStart.Done) + } catch (e: Exception) { + handlerError(e) { + _bluetoothActionState.value = Event(BluetoothActionState.DiscoveryStart.Error(e)) + } + } + } + } + + fun stopNearBluetoothDevicesSearch() { + viewModelScope.launch { + try { + _bluetoothActionState.value = Event(BluetoothActionState.DiscoveryStop.Loading) + bluetoothManager.stopDiscovery() + _bluetoothActionState.value = Event(BluetoothActionState.DiscoveryStop.Done) + } catch (e: Exception) { + handlerError(e) { + _bluetoothActionState.value = Event(BluetoothActionState.DiscoveryStop.Error(e)) + } + } + } + } + + fun pairDevice(bluetoothDevice: BluetoothDevice) { + viewModelScope.launch { + try { + _bluetoothActionState.value = Event(BluetoothActionState.Pair.Loading) + bluetoothManager.pairDevice(bluetoothDevice) + _bluetoothActionState.value = Event(BluetoothActionState.Pair.Done) + + // Update Paired Devices List + retrievePairedDevices() + } 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.unpair(bluetoothDevice.address) + _bluetoothActionState.value = Event(BluetoothActionState.UnPair.Done) + + // Update Paired Devices List + val newPairedDevices = _pairedDevices.value?.toMutableList()?.apply { + removeAll { + it.address == bluetoothDevice.address + } + } + + _pairedDevices.value = newPairedDevices ?: listOf() + } catch (e: Exception) { + handlerError(e) { + _bluetoothActionState.value = Event(BluetoothActionState.UnPair.Error(e)) + } + } + } + } + + private fun handlerError(error: Exception, callback: () -> 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 -> callback.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 DiscoveryStart : BluetoothActionState() { + object Loading : DiscoveryStart() + object Done : DiscoveryStart() + class Error(error: Exception) : DiscoveryStart() + } + + sealed class DiscoveryStop : BluetoothActionState() { + object Loading : DiscoveryStop() + object Done : DiscoveryStop() + class Error(error: Exception) : DiscoveryStop() + } +} 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..b081409 --- /dev/null +++ b/app/src/main/res/layout/fragment_bluetooth.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +