Merge pull request #383 from mcumings/369-bluetooth

Issue #369 - Use repository pattern for bluetooth state
This commit is contained in:
Andre Kirchhoff
2022-02-27 10:04:33 -03:00
committed by GitHub
11 changed files with 224 additions and 109 deletions

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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<String> {
@@ -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 {

View File

@@ -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()
}

View File

@@ -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<String>("MrIDE Test") {
}
val bluetoothEnabled = object : MutableLiveData<Boolean>(false) {
}
val provideLocation = object : MutableLiveData<Boolean>(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) {

View File

@@ -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<BluetoothAdapter>,
private val bluetoothStateReceiverLazy: dagger.Lazy<BluetoothStateReceiver>,
private val dispatchers: CoroutineDispatchers,
private val processLifecycle: Lifecycle,
) : Logging {
internal val enabledInternal = MutableStateFlow(false)
val enabled: StateFlow<Boolean> = 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}")
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}

View File

@@ -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
)
}

View File

@@ -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
}

View File

@@ -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)
}