From b3878a4240498ee35a95ba75a2277894c62bd9d1 Mon Sep 17 00:00:00 2001 From: Mike Cumings Date: Sat, 26 Feb 2022 22:59:20 -0800 Subject: [PATCH] Issue #369 - Use repository pattern for bluetooth state --- .../com/geeksville/mesh/ApplicationModule.kt | 13 +++++ .../geeksville/mesh/CoroutineDispatchers.kt | 15 +++++ .../java/com/geeksville/mesh/MainActivity.kt | 55 ++++-------------- .../mesh/model/BluetoothViewModel.kt | 19 +++++++ .../java/com/geeksville/mesh/model/UIState.kt | 11 ---- .../bluetooth/BluetoothRepository.kt | 56 +++++++++++++++++++ .../bluetooth/BluetoothRepositoryModule.kt | 26 +++++++++ .../bluetooth/BluetoothStateReceiver.kt | 43 ++++++++++++++ .../mesh/service/BluetoothStateReceiver.kt | 34 ----------- .../mesh/service/RadioInterfaceService.kt | 55 ++++++++++++------ .../geeksville/mesh/ui/SettingsFragment.kt | 6 +- 11 files changed, 224 insertions(+), 109 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/CoroutineDispatchers.kt create mode 100644 app/src/main/java/com/geeksville/mesh/model/BluetoothViewModel.kt create mode 100644 app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt create mode 100644 app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt create mode 100644 app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothStateReceiver.kt delete mode 100644 app/src/main/java/com/geeksville/mesh/service/BluetoothStateReceiver.kt diff --git a/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt b/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt index 834bfea7d..b84d8b553 100644 --- a/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt +++ b/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt @@ -3,6 +3,9 @@ package com.geeksville.mesh import android.app.Application import android.content.Context import android.content.SharedPreferences +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -15,4 +18,14 @@ object ApplicationModule { fun provideSharedPreferences(application: Application): SharedPreferences { return application.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE) } + + @Provides + fun provideProcessLifecycleOwner(): LifecycleOwner { + return ProcessLifecycleOwner.get() + } + + @Provides + fun provideProcessLifecycle(processLifecycleOwner: LifecycleOwner): Lifecycle { + return processLifecycleOwner.lifecycle + } } \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/CoroutineDispatchers.kt b/app/src/main/java/com/geeksville/mesh/CoroutineDispatchers.kt new file mode 100644 index 000000000..9922b19eb --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/CoroutineDispatchers.kt @@ -0,0 +1,15 @@ +package com.geeksville.mesh + +import kotlinx.coroutines.Dispatchers +import javax.inject.Inject + +/** + * Wrapper around `Dispatchers` to allow for easier testing when using dispatchers + * in injected classes. + */ +class CoroutineDispatchers @Inject constructor() { + val main = Dispatchers.Main + val mainImmediate = Dispatchers.Main.immediate + val default = Dispatchers.Default + val io = Dispatchers.IO +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 044dbf6f9..0782ec62f 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -4,7 +4,6 @@ import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothManager import android.content.* import android.content.pm.PackageInfo import android.content.pm.PackageManager @@ -40,6 +39,7 @@ import com.geeksville.android.ServiceClient import com.geeksville.concurrent.handledLaunch import com.geeksville.mesh.android.* import com.geeksville.mesh.databinding.ActivityMainBinding +import com.geeksville.mesh.model.BluetoothViewModel import com.geeksville.mesh.model.ChannelSet import com.geeksville.mesh.model.DeviceVersion import com.geeksville.mesh.model.UIViewModel @@ -134,11 +134,7 @@ class MainActivity : AppCompatActivity(), Logging, // Used to schedule a coroutine in the GUI thread private val mainScope = CoroutineScope(Dispatchers.Main + Job()) - private val bluetoothAdapter: BluetoothAdapter? by lazy(LazyThreadSafetyMode.NONE) { - val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager - bluetoothManager.adapter - } - + val bluetoothViewModel: BluetoothViewModel by viewModels() val model: UIViewModel by viewModels() data class TabInfo(val text: String, val icon: Int, val content: Fragment) @@ -187,28 +183,6 @@ class MainActivity : AppCompatActivity(), Logging, } } - private val btStateReceiver = BluetoothStateReceiver { - updateBluetoothEnabled() - } - - /** - * Don't tell our app we have bluetooth until we have bluetooth _and_ location access - */ - private fun updateBluetoothEnabled() { - var enabled = false // assume failure - - if (hasConnectPermission()) { - /// ask the adapter if we have access - bluetoothAdapter?.apply { - enabled = isEnabled - } - } else - errormsg("Still missing needed bluetooth permissions") - - debug("Detected our bluetooth access=$enabled") - model.bluetoothEnabled.value = enabled - } - /** Get the minimum permissions our app needs to run correctly */ private fun getMinimumPermissions(): List { @@ -381,7 +355,7 @@ class MainActivity : AppCompatActivity(), Logging, } } - updateBluetoothEnabled() + bluetoothViewModel.refreshState() } @@ -445,12 +419,6 @@ class MainActivity : AppCompatActivity(), Logging, /// Set theme setUITheme(prefs) - /// Set initial bluetooth state - updateBluetoothEnabled() - - /// We now want to be informed of bluetooth state - registerReceiver(btStateReceiver, btStateReceiver.intentFilter) - /* not yet working // Configure sign-in to request the user's ID, email address, and basic // profile. ID and basic profile are included in DEFAULT_SIGN_IN. @@ -569,7 +537,6 @@ class MainActivity : AppCompatActivity(), Logging, } override fun onDestroy() { - unregisterReceiver(btStateReceiver) unregisterMeshReceiver() mainScope.cancel("Activity going away") super.onDestroy() @@ -1003,17 +970,17 @@ class MainActivity : AppCompatActivity(), Logging, override fun onStart() { super.onStart() - // Ask to start bluetooth if no USB devices are visible - val hasUSB = SerialInterface.findDrivers(this).isNotEmpty() - if (!isInTestLab && !hasUSB) { - if (hasConnectPermission()) { - bluetoothAdapter?.let { - if (!it.isEnabled) { + bluetoothViewModel.enabled.observe(this) { enabled -> + if (!enabled) { + // Ask to start bluetooth if no USB devices are visible + val hasUSB = SerialInterface.findDrivers(this).isNotEmpty() + if (!isInTestLab && !hasUSB) { + if (hasConnectPermission()) { val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) - } + } else requestPermission() } - } else requestPermission() + } } try { diff --git a/app/src/main/java/com/geeksville/mesh/model/BluetoothViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/BluetoothViewModel.kt new file mode 100644 index 000000000..87631a685 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/BluetoothViewModel.kt @@ -0,0 +1,19 @@ +package com.geeksville.mesh.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import com.geeksville.mesh.repository.bluetooth.BluetoothRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * Thin view model which adapts the view layer to the `BluetoothRepository`. + */ +@HiltViewModel +class BluetoothViewModel @Inject constructor( + private val bluetoothRepository: BluetoothRepository, +) : ViewModel() { + fun refreshState() = bluetoothRepository.refreshState() + + val enabled = bluetoothRepository.enabled.asLiveData() +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index e4b5dfe51..11e5daf5b 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -74,10 +74,6 @@ class UIViewModel @Inject constructor( debug("ViewModel created") } - fun insertPacket(packet: Packet) = viewModelScope.launch(Dispatchers.IO) { - repository.insert(packet) - } - fun deleteAllPacket() = viewModelScope.launch(Dispatchers.IO) { repository.deleteAll() } @@ -229,10 +225,6 @@ class UIViewModel @Inject constructor( val ownerName = object : MutableLiveData("MrIDE Test") { } - - val bluetoothEnabled = object : MutableLiveData(false) { - } - val provideLocation = object : MutableLiveData(preferences.getBoolean(MyPreferences.provideLocationKey, false)) { override fun setValue(value: Boolean) { super.setValue(value) @@ -243,9 +235,6 @@ class UIViewModel @Inject constructor( } } - /// If the app was launched because we received a new channel intent, the Url will be here - var requestedChannelUrl: Uri? = null - // clean up all this nasty owner state management FIXME fun setOwner(s: String? = null) { diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt new file mode 100644 index 000000000..5f9a6d159 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt @@ -0,0 +1,56 @@ +package com.geeksville.mesh.repository.bluetooth + +import android.app.Application +import android.bluetooth.BluetoothAdapter +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import com.geeksville.android.Logging +import com.geeksville.mesh.CoroutineDispatchers +import com.geeksville.mesh.android.hasConnectPermission +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Repository responsible for maintaining and updating the state of Bluetooth availability. + */ +@Singleton +class BluetoothRepository @Inject constructor( + private val application: Application, + private val bluetoothAdapterLazy: dagger.Lazy, + private val bluetoothStateReceiverLazy: dagger.Lazy, + private val dispatchers: CoroutineDispatchers, + private val processLifecycle: Lifecycle, +) : Logging { + internal val enabledInternal = MutableStateFlow(false) + val enabled: StateFlow = enabledInternal + + init { + processLifecycle.coroutineScope.launch(dispatchers.default) { + updateBluetoothEnabled() + bluetoothStateReceiverLazy.get().let { receiver -> + application.registerReceiver(receiver, receiver.intentFilter) + } + } + } + + fun refreshState() { + processLifecycle.coroutineScope.launch(dispatchers.default) { + updateBluetoothEnabled() + } + } + + private suspend fun updateBluetoothEnabled() { + if (application.hasConnectPermission()) { + /// ask the adapter if we have access + bluetoothAdapterLazy.get()?.let { adapter -> + enabledInternal.emit(adapter.isEnabled) + } + } else + errormsg("Still missing needed bluetooth permissions") + + debug("Detected our bluetooth access=${enabled.value}") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt new file mode 100644 index 000000000..9aa992bc8 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt @@ -0,0 +1,26 @@ +package com.geeksville.mesh.repository.bluetooth + +import android.app.Application +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface BluetoothRepositoryModule { + companion object { + @Provides + fun provideBluetoothManager(application: Application): BluetoothManager { + return application.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + } + + @Provides + fun provideBluetoothAdapter(service: BluetoothManager): BluetoothAdapter { + return service.adapter + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothStateReceiver.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothStateReceiver.kt new file mode 100644 index 000000000..2c288ef34 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothStateReceiver.kt @@ -0,0 +1,43 @@ +package com.geeksville.mesh.repository.bluetooth + +import android.bluetooth.BluetoothAdapter +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import com.geeksville.mesh.CoroutineDispatchers +import com.geeksville.util.exceptionReporter +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * A helper class to call onChanged when bluetooth is enabled or disabled + */ +class BluetoothStateReceiver @Inject constructor( + private val dispatchers: CoroutineDispatchers, + private val bluetoothRepository: BluetoothRepository, + private val processLifecycle: Lifecycle, +) : BroadcastReceiver() { + internal val intentFilter get() = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) // Can be used for registering + + override fun onReceive(context: Context, intent: Intent) = exceptionReporter { + if (intent.action == BluetoothAdapter.ACTION_STATE_CHANGED) { + when (intent.bluetoothAdapterState) { + // Simulate a disconnection if the user disables bluetooth entirely + BluetoothAdapter.STATE_OFF -> emitState(false) + BluetoothAdapter.STATE_ON -> emitState(true) + } + } + } + + private fun emitState(newState: Boolean) { + processLifecycle.coroutineScope.launch(dispatchers.default) { + bluetoothRepository.enabledInternal.emit(newState) + } + } + + private val Intent.bluetoothAdapterState: Int + get() = getIntExtra(BluetoothAdapter.EXTRA_STATE,-1) +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/service/BluetoothStateReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/BluetoothStateReceiver.kt deleted file mode 100644 index a4edefb51..000000000 --- a/app/src/main/java/com/geeksville/mesh/service/BluetoothStateReceiver.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.geeksville.mesh.service - -import android.bluetooth.BluetoothAdapter -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import com.geeksville.util.exceptionReporter - -/** - * A helper class to call onChanged when bluetooth is enabled or disabled - */ -class BluetoothStateReceiver( - private val onChanged: (Boolean) -> Unit -) : BroadcastReceiver() { - - val intentFilter get() = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) // Can be used for registering - - override fun onReceive(context: Context, intent: Intent) = exceptionReporter { - if (intent.action == BluetoothAdapter.ACTION_STATE_CHANGED) { - when (intent.bluetoothAdapterState) { - // Simulate a disconnection if the user disables bluetooth entirely - BluetoothAdapter.STATE_OFF -> onChanged(false) - BluetoothAdapter.STATE_ON -> onChanged(true) - } - } - } - - private val Intent.bluetoothAdapterState: Int - get() = getIntExtra( - BluetoothAdapter.EXTRA_STATE, - -1 - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt index bd9b5eead..7ca4ea79f 100644 --- a/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt @@ -2,24 +2,27 @@ package com.geeksville.mesh.service import android.annotation.SuppressLint import android.app.Service -import android.companion.CompanionDeviceManager import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.os.IBinder import androidx.core.content.edit +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ServiceLifecycleDispatcher +import androidx.lifecycle.coroutineScope import com.geeksville.android.BinaryLogFile import com.geeksville.android.GeeksvilleApplication import com.geeksville.android.Logging import com.geeksville.concurrent.handledLaunch import com.geeksville.mesh.IRadioInterfaceService +import com.geeksville.mesh.repository.bluetooth.BluetoothRepository import com.geeksville.util.anonymize import com.geeksville.util.ignoreException import com.geeksville.util.toRemoteExceptions -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.collect +import javax.inject.Inject open class RadioNotConnectedException(message: String = "Not connected to radio") : @@ -35,8 +38,18 @@ open class RadioNotConnectedException(message: String = "Not connected to radio" * Note - this class intentionally dumb. It doesn't understand protobuf framing etc... * It is designed to be simple so it can be stubbed out with a simulated version as needed. */ +@AndroidEntryPoint class RadioInterfaceService : Service(), Logging { + // The following is due to the fact that AIDL prevents us from extending from `LifecycleService`: + private val lifecycleOwner: LifecycleOwner = LifecycleOwner { lifecycleDispatcher.lifecycle } + private val lifecycleDispatcher: ServiceLifecycleDispatcher by lazy { + ServiceLifecycleDispatcher(lifecycleOwner) + } + + @Inject + lateinit var bluetoothRepository: BluetoothRepository + companion object : Logging { /** * The RECEIVED_FROMRADIO @@ -104,7 +117,7 @@ class RadioInterfaceService : Service(), Logging { @SuppressLint("NewApi") fun getBondedDeviceAddress(context: Context): String? { // If the user has unpaired our device, treat things as if we don't have one - var address = getDeviceAddress(context) + val address = getDeviceAddress(context) /// Interfaces can filter addresses to indicate that address is no longer acceptable if (address != null) { @@ -142,16 +155,6 @@ class RadioInterfaceService : Service(), Logging { /// true if our interface is currently connected to a device private var isConnected = false - /** - * If the user turns on bluetooth after we start, make sure to try and reconnected then - */ - private val bluetoothStateReceiver = BluetoothStateReceiver { enabled -> - if (enabled) - startInterface() // If bluetooth just got turned on, try to restart our ble link (which might be bluetooth) - else if (radioIf is BluetoothInterface) - stopInterface() // Was using bluetooth, need to shutdown - } - private fun broadcastConnectionChanged(isConnected: Boolean, isPermanent: Boolean) { debug("Broadcasting connection=$isConnected") val intent = Intent(RADIO_CONNECTED_ACTION) @@ -197,19 +200,35 @@ class RadioInterfaceService : Service(), Logging { override fun onCreate() { runningService = this + lifecycleDispatcher.onServicePreSuperOnCreate() super.onCreate() - registerReceiver(bluetoothStateReceiver, bluetoothStateReceiver.intentFilter) + + lifecycleOwner.lifecycle.coroutineScope.launch { + bluetoothRepository.enabled.collect { enabled -> + if (enabled) { + startInterface() + } else { + stopInterface() + } + } + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + lifecycleDispatcher.onServicePreSuperOnStart() + return super.onStartCommand(intent, flags, startId) } override fun onDestroy() { - unregisterReceiver(bluetoothStateReceiver) stopInterface() serviceScope.cancel("Destroying RadioInterface") runningService = null + lifecycleDispatcher.onServicePreSuperOnDestroy() super.onDestroy() } override fun onBind(intent: Intent?): IBinder? { + lifecycleDispatcher.onServicePreSuperOnBind() return binder } diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt index b44a31fd1..89717f296 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -33,6 +33,7 @@ import com.geeksville.mesh.R import com.geeksville.mesh.RadioConfigProtos import com.geeksville.mesh.android.* import com.geeksville.mesh.databinding.SettingsFragmentBinding +import com.geeksville.mesh.model.BluetoothViewModel import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.service.* import com.geeksville.mesh.service.SoftwareUpdateService.Companion.ACTION_UPDATE_PROGRESS @@ -447,6 +448,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { private val binding get() = _binding!! private val scanModel: BTScanModel by activityViewModels() + private val bluetoothViewModel: BluetoothViewModel by activityViewModels() private val model: UIViewModel by activityViewModels() // FIXME - move this into a standard GUI helper class @@ -624,7 +626,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { regionAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) spinner.adapter = regionAdapter - model.bluetoothEnabled.observe(viewLifecycleOwner) { + bluetoothViewModel.enabled.observe(viewLifecycleOwner) { if (it) binding.changeRadioButton.show() else binding.changeRadioButton.hide() } @@ -813,7 +815,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { if (curRadio != null && !MockInterface.addressValid(requireContext(), "")) { binding.warningNotPaired.visibility = View.GONE // binding.scanStatusText.text = getString(R.string.current_pair).format(curRadio) - } else if (model.bluetoothEnabled.value == true){ + } else if (bluetoothViewModel.enabled.value == true){ binding.warningNotPaired.visibility = View.VISIBLE binding.scanStatusText.text = getString(R.string.not_paired_yet) }