diff --git a/app/build.gradle b/app/build.gradle index 9dcd3403e..e038e8315 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -43,8 +43,8 @@ android { applicationId "com.geeksville.mesh" minSdkVersion 21 // The oldest emulator image I have tried is 22 (though 21 probably works) targetSdkVersion 30 // 30 can't work until an explicit location permissions dialog is added - versionCode 20258 // format is Mmmss (where M is 1+the numeric major number - versionName "1.2.58" + versionCode 20259 // format is Mmmss (where M is 1+the numeric major number + versionName "1.2.59" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // per https://developer.android.com/studio/write/vector-asset-studio @@ -122,7 +122,7 @@ protobuf { dependencies { - def room_version = '2.4.1' + def room_version = '2.4.2' implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'androidx.appcompat:appcompat:1.4.1' @@ -134,7 +134,7 @@ dependencies { implementation 'com.google.android.material:material:1.5.0' implementation 'androidx.viewpager2:viewpager2:1.0.0' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1' implementation "androidx.room:room-runtime:$room_version" implementation "com.google.dagger:hilt-android:$hilt_version" kapt "androidx.room:room-compiler:$room_version" 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..d7bf8c0dd 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 - } - + private 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.permissionsUpdated() } @@ -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/database/PacketRepository.kt b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt index 06a7ff841..e8a7077c9 100644 --- a/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt @@ -16,8 +16,8 @@ class PacketRepository @Inject constructor(private val packetDaoLazy: dagger.Laz packetDao.getAllPacket(MAX_ITEMS) } - suspend fun getAllPacketsInReceiveOrder(): Flow> = withContext(Dispatchers.IO) { - packetDao.getAllPacketsInReceiveOrder(MAX_ITEMS) + suspend fun getAllPacketsInReceiveOrder(maxItems: Int = MAX_ITEMS): Flow> = withContext(Dispatchers.IO) { + packetDao.getAllPacketsInReceiveOrder(maxItems) } suspend fun insert(packet: Packet) = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt b/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt index ad516e1ec..bc775f205 100644 --- a/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt +++ b/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt @@ -16,7 +16,7 @@ data class Packet(@PrimaryKey val uuid: String, @ColumnInfo(name = "message") val raw_message: String ) { - val proto: MeshProtos.MeshPacket? + val meshPacket: MeshProtos.MeshPacket? get() { if (message_type == "packet") { val builder = MeshProtos.MeshPacket.newBuilder() @@ -28,13 +28,27 @@ data class Packet(@PrimaryKey val uuid: String, } return null } + + val nodeInfo: MeshProtos.NodeInfo? + get() { + if (message_type == "NodeInfo") { + val builder = MeshProtos.NodeInfo.newBuilder() + try { + TextFormat.getParser().merge(raw_message, builder) + return builder.build() + } catch (e: IOException) { + } + } + return null + } + val position: MeshProtos.Position? get() { - return proto?.run { + return meshPacket?.run { if (hasDecoded() && decoded.portnumValue == Portnums.PortNum.POSITION_APP_VALUE) { return MeshProtos.Position.parseFrom(decoded.payload) } return null - } + } ?: nodeInfo?.position } } \ No newline at end of file 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..6562f41be --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/BluetoothViewModel.kt @@ -0,0 +1,24 @@ +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 kotlinx.coroutines.flow.map +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() { + /** + * Called when permissions have been updated. This causes an explicit refresh of the + * bluetooth state. + */ + fun permissionsUpdated() = bluetoothRepository.refreshState() + + val enabled = bluetoothRepository.state.map { it.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 6b0600e2e..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) { @@ -283,66 +272,83 @@ class UIViewModel @Inject constructor( // Capture the current node value while we're still on main thread val nodes = nodeDB.nodes.value ?: emptyMap() + val positionToPos: (MeshProtos.Position?) -> Position? = { meshPosition -> + meshPosition?.let { Position(it) }.takeIf { + it?.isValid() == true + } + } + writeToUri(file_uri) { writer -> // Create a map of nodes keyed by their ID - val nodesById = nodes.values.associateBy { it.num } + val nodesById = nodes.values.associateBy { it.num }.toMutableMap() + val nodePositions = mutableMapOf() writer.appendLine("date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx elevation,rx snr,distance,hop limit,payload") // Packets are ordered by time, we keep most recent position of // our device in localNodePosition. - var localNodePosition: MeshProtos.Position? = null val dateFormat = SimpleDateFormat("yyyy-MM-dd,HH:mm:ss", Locale.getDefault()) - repository.getAllPacketsInReceiveOrder().first().forEach { packet -> - packet.proto?.let { proto -> + repository.getAllPacketsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet -> + // If we get a NodeInfo packet, use it to update our position data (if valid) + packet.nodeInfo?.let { nodeInfo -> + positionToPos.invoke(nodeInfo.position)?.let { _ -> + nodePositions[nodeInfo.num] = nodeInfo.position + } + } + + packet.meshPacket?.let { proto -> + // If the packet contains position data then use it to update, if valid packet.position?.let { position -> - if (proto.from == myNodeNum) { - localNodePosition = position - } else { - val rxDateTime = dateFormat.format(packet.received_date) - val rxFrom = proto.from.toUInt() - val senderName = nodesById[proto.from]?.user?.longName ?: "" - - // sender lat & long - val senderPos = packet.position - ?.let { p -> Position(p) } - ?.takeIf { p -> p.isValid() } - val senderLat = senderPos?.latitude ?: "" - val senderLong = senderPos?.longitude ?: "" - - // rx lat, long, and elevation - val rxPos = localNodePosition - ?.let { p -> Position(p) } - ?.takeIf { p -> p.isValid() } - val rxLat = rxPos?.latitude ?: "" - val rxLong = rxPos?.longitude ?: "" - val rxAlt = rxPos?.altitude ?: "" - val rxSnr = "%f".format(proto.rxSnr) - - // Calculate the distance if both positions are valid - val dist = if (senderPos == null || rxPos == null) { - "" - } else { - positionToMeter( - localNodePosition!!, - position - ).roundToInt().toString() - } - - val hopLimit = proto.hopLimit - - val payload = when { - proto.decoded.portnumValue != Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> "<${proto.decoded.portnum}>" - proto.hasDecoded() -> "\"" + proto.decoded.payload.toStringUtf8() - .replace("\"", "\\\"") + "\"" - proto.hasEncrypted() -> "${proto.encrypted.size()} encrypted bytes" - else -> "" - } - - // date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx elevation,rx snr,distance,hop limit,payload - writer.appendLine("$rxDateTime,$rxFrom,$senderName,$senderLat,$senderLong,$rxLat,$rxLong,$rxAlt,$rxSnr,$dist,$hopLimit,$payload") + positionToPos.invoke(position)?.let { _ -> + nodePositions[proto.from] = position } } + + // Filter out of our results any packet that doesn't report SNR. This + // is primarily ADMIN_APP. + if (proto.rxSnr > 0.0f) { + val rxDateTime = dateFormat.format(packet.received_date) + val rxFrom = proto.from.toUInt() + val senderName = nodesById[proto.from]?.user?.longName ?: "" + + // sender lat & long + val senderPosition = nodePositions[proto.from] + val senderPos = positionToPos.invoke(senderPosition) + val senderLat = senderPos?.latitude ?: "" + val senderLong = senderPos?.longitude ?: "" + + // rx lat, long, and elevation + val rxPosition = nodePositions[myNodeNum] + val rxPos = positionToPos.invoke(rxPosition) + val rxLat = rxPos?.latitude ?: "" + val rxLong = rxPos?.longitude ?: "" + val rxAlt = rxPos?.altitude ?: "" + val rxSnr = "%f".format(proto.rxSnr) + + // Calculate the distance if both positions are valid + + val dist = if (senderPos == null || rxPos == null) { + "" + } else { + positionToMeter( + rxPosition!!, // Use rxPosition but only if rxPos was valid + senderPosition!! // Use senderPosition but only if senderPos was valid + ).roundToInt().toString() + } + + val hopLimit = proto.hopLimit + + val payload = when { + proto.decoded.portnumValue != Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> "<${proto.decoded.portnum}>" + proto.hasDecoded() -> "\"" + proto.decoded.payload.toStringUtf8() + .replace("\"", "\\\"") + "\"" + proto.hasEncrypted() -> "${proto.encrypted.size()} encrypted bytes" + else -> "" + } + + // date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx elevation,rx snr,distance,hop limit,payload + writer.appendLine("$rxDateTime,$rxFrom,$senderName,$senderLat,$senderLong,$rxLat,$rxLong,$rxAlt,$rxSnr,$dist,$hopLimit,$payload") + } } } } diff --git a/app/src/main/java/com/geeksville/mesh/service/BluetoothStateReceiver.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothBroadcastReceiver.kt similarity index 57% rename from app/src/main/java/com/geeksville/mesh/service/BluetoothStateReceiver.kt rename to app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothBroadcastReceiver.kt index a4edefb51..3f4c2ee79 100644 --- a/app/src/main/java/com/geeksville/mesh/service/BluetoothStateReceiver.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothBroadcastReceiver.kt @@ -1,4 +1,4 @@ -package com.geeksville.mesh.service +package com.geeksville.mesh.repository.bluetooth import android.bluetooth.BluetoothAdapter import android.content.BroadcastReceiver @@ -6,29 +6,26 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import com.geeksville.util.exceptionReporter +import javax.inject.Inject /** * A helper class to call onChanged when bluetooth is enabled or disabled */ -class BluetoothStateReceiver( - private val onChanged: (Boolean) -> Unit +class BluetoothBroadcastReceiver @Inject constructor( + private val bluetoothRepository: BluetoothRepository ) : BroadcastReceiver() { - - val intentFilter get() = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) // Can be used for registering + 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 -> onChanged(false) - BluetoothAdapter.STATE_ON -> onChanged(true) + BluetoothAdapter.STATE_OFF -> bluetoothRepository.refreshState() + BluetoothAdapter.STATE_ON -> bluetoothRepository.refreshState() } } } private val Intent.bluetoothAdapterState: Int - get() = getIntExtra( - BluetoothAdapter.EXTRA_STATE, - -1 - ) + get() = getIntExtra(BluetoothAdapter.EXTRA_STATE,-1) } \ No newline at end of file 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..4502783ae --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt @@ -0,0 +1,102 @@ +package com.geeksville.mesh.repository.bluetooth + +import android.annotation.SuppressLint +import android.app.Application +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.le.BluetoothLeScanner +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.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +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 bluetoothBroadcastReceiverLazy: dagger.Lazy, + private val dispatchers: CoroutineDispatchers, + private val processLifecycle: Lifecycle, +) : Logging { + private val _state = MutableStateFlow(BluetoothState( + // Assume we have permission until we get our initial state update to prevent premature + // notifications to the user. + hasPermissions = true + )) + val state: StateFlow = _state.asStateFlow() + + init { + processLifecycle.coroutineScope.launch(dispatchers.default) { + updateBluetoothState() + bluetoothBroadcastReceiverLazy.get().let { receiver -> + application.registerReceiver(receiver, receiver.intentFilter) + } + } + } + + fun refreshState() { + processLifecycle.coroutineScope.launch(dispatchers.default) { + updateBluetoothState() + } + } + + fun getRemoteDevice(address: String): BluetoothDevice? { + return bluetoothAdapterLazy.get()?.getRemoteDevice(address) + } + + fun getBluetoothLeScanner(): BluetoothLeScanner? { + return bluetoothAdapterLazy.get()?.bluetoothLeScanner + } + + @SuppressLint("MissingPermission") + internal suspend fun updateBluetoothState() { + val newState: BluetoothState = bluetoothAdapterLazy.get()?.takeIf { + application.hasConnectPermission().also { hasPerms -> + if (!hasPerms) errormsg("Still missing needed bluetooth permissions") + } + }?.let { adapter -> + /// ask the adapter if we have access + BluetoothState( + hasPermissions = true, + enabled = adapter.isEnabled, + bondedDevices = createBondedDevicesFlow(adapter), + ) + } ?: BluetoothState() + + _state.emit(newState) + debug("Detected our bluetooth access=$newState") + } + + /** + * Creates a cold Flow used to obtain the set of bonded devices. + */ + @SuppressLint("MissingPermission") // Already checked prior to calling + private suspend fun createBondedDevicesFlow(adapter: BluetoothAdapter): Flow>? { + return if (adapter.isEnabled) { + flow> { + withContext(dispatchers.default) { + while (true) { + emit(adapter.bondedDevices) + delay(REFRESH_DELAY_MS) + } + } + }.flowOn(dispatchers.default) + } else { + null + } + } + + companion object { + const val REFRESH_DELAY_MS = 1000L + } +} \ 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..7974b9b31 --- /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/BluetoothState.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothState.kt new file mode 100644 index 000000000..895afc9d9 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothState.kt @@ -0,0 +1,16 @@ +package com.geeksville.mesh.repository.bluetooth + +import android.bluetooth.BluetoothDevice +import kotlinx.coroutines.flow.Flow + +/** + * A snapshot in time of the state of the bluetooth subsystem. + */ +data class BluetoothState( + /** Whether we have adequate permissions to query bluetooth state */ + val hasPermissions: Boolean = false, + /** If we have adequate permissions and bluetooth is enabled */ + val enabled: Boolean = false, + /** If enabled, a cold flow of the currently bonded devices */ + val bondedDevices: Flow>? = null +) diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index ac0737ce8..fe3672049 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -1560,7 +1560,7 @@ class MeshService : Service(), Logging { try { val mi = myNodeInfo if (mi != null) { - debug("Sending our position/time to=$destNum lat=$lat, lon=$lon, alt=$alt") + debug("Sending our position/time to=$destNum lat=${lat.anonymize}, lon=${lon.anonymize}, alt=$alt") val position = MeshProtos.Position.newBuilder().also { it.longitudeI = Position.degI(lon) 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..9b6366699 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.state.collect { state -> + if (state.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) } diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index d29c99100..23fb43959 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -48,7 +48,7 @@ Sdílet Odpojeno Zařízení spí - Pripojeno: %s z %s je online + Pripojeno: %1$s z %2$s je online Seznam vysílačů v síti Aktualizace softwaru Připojeno k vysílači (%s) diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 72f5ff439..31072a981 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1,142 +1,73 @@ - Ρυθμίσεις - Όνομα Καναλιού - Επιλογές Καναλιού - Κοινή χρήση - Κώδικας QR - Αναίρεση - Κατάσταση Σύνδεσης - Εικονίδιο εφαρμογής - Άγνωστο Όνομα Χρήστη - Avatar Χρήστη - - Βρήκα το σημείο, είναι διπλα στη τίγρη. Φοβάμαι λίγο. - Αποστολή κειμένου - Δεν έχετε κάνει ακόμη pair μια συσκευή συμβατή με Meshtastic με το τηλέφωνο. Παρακαλώ κάντε pair μια συσκευή και ορίστε το όνομα χρήστη.\n\nΗ εφαρμογή ανοιχτού κώδικα βρίσκεται σε alpha-testing, αν εντοπίσετε προβλήματα παρακαλώ δημοσιεύστε τα στο forum: meshtastic.discourse.group.\n\nΠερισσότερες πληροφορίες στην ιστοσελίδα - www.meshtastic.org. - Όνομα Χρήστη αναιρέθηκε - Όνομα - Ανώνυμα στατιστικά χρήσης και αναφορές crash. - Αναζήτηση συσκευών Meshtastic … - Η εφαρμογή απαιτεί bluetooth πρόσβαση. Παρακαλώ παρέχεται σχετική άδεια χρήσης στις ρυθμίσεις του android. - Σφάλμα - η εφαρμογή απαιτεί bluetooth - Αρχή pairing - Pairing απέτυχε - Διεύθυνση URL για συμμετοχή σε Meshtastic mesh - Αποδοχή - Ακύρωση - Αλλαγή καναλιού - Είστε βέβαιοι ότι θέλετε να αλλάξετε κανάλι? Η επικοινωνία με άλλες συσκευές θα σταματήσεις μέχρι να μοιραστείτε τις ρυθμίσεις του νέου καναλιού. - Λήψη URL νέου καναλιού - Θέλετε να αλλάξετε ‘%s’ κανάλι? - Έχετε απενεργοποιήσει την ανάλυση δεδομένων. Δυστυχώς ο πάροχος χαρτών μας (mapbox) απαιτεί η ανάλυση δεδομένων να επιτρέπεται στο ‘δωρεάν’ πακέτο. Επομένως έχουμε απενεργοποιήσει το χάρτη.\n\n Αν επιθυμείτε να δείτε το χάρτη, θα πρέπει να ενεργοποιήσετε την ανάλυση δεδομένων στις Ρυθμίσεις (επίσης - προσωρινά - θα πρέπει να επανεκκινήσετε την εφαρμογή).\n\n Αν θεωρείτε ότι πρέπει να πληρώνουμε το mapbox (η να αλλάξουμε πάροχο χαρτών), παρακαλώ δημοσιεύστε στο meshtastic.discourse.group - Λείπει μια απαιτούμενη άδεια, Meshtastic δεν θα λειτοργεί σωστά. Ενεργοποιήστε τις ρυθμίσεις εφαρμογής Android. - Radio σε κατάσταση ύπνου, δεν γίνεται αλλαγή καναλιού - Αναφορά Bug - Αναφέρετε ένα bug - Είστε σίγουροι ότι θέλετε να αναφέρετε ένα bug? Μετά την αναφορά δημοσιεύστε στο meshtastic.discourse.group ώστε να συνδέσουμε την αναφορά με το συμβάν. - Αναφορά - Επιλογή radio - Έχετε κάνει pair με radio %s - Δεν έχετε κάνει pair με radio ακόμη. - Αλλαγή radio - Παρακαλώ κάντε pair μια συσκευή στις ρυθμίσεις Android. - Η διαδικασία pairing ολοκληρώθηκε, εκκίνηση υπηρεσίας - Η διαδικασία pairing απέτυχε, παρακαλώ επιλέξτε πάλι - Ο εντοπισμός τοποθεσίας είναι απενεργοποιημένος, δε μπορούμε να μοιραστούμε τη θέση σας με το mesh. - Κοινοποίηση - Αποσυνδεδεμένο - Συσκευή σε ύπνωση - - Συνδεδεμένος: %s από %s online - + Συνδεδεμένος: %1$s από %2$s online Λίστα κόμβων δικτύου - Αναβάθμιση Firmware - Συνδεδεμένο στο radio - Συνδεδεμένο στο radio (%s) - Αποσυνδεδεμένο, επιλέξτε radio - Συνδεδεμένο στο radio, αλλά βρίσκεται σε ύπνωση - Αναβάθμιση σε %s - Εφαρμογή πολύ παλαιά - Πρέπει να ενημερώσετε την εφαρμογή μέσω Google Play store (ή Github). Είναι πολύ παλαιά ώστε να συνδεθεί με το radio. - Κανένα (απενεργοποιημένο) - Μικρή εμβέλεια (αλλά γρήγορο) - Μεσαία εμβέλεια (αλλά γρήγορο) - Μεγάλη εμβέλεια (αλλά αργό) - Πολύ μεγάλη εμβέλεια (αλλά αργό) - ΜΗ ΑΝΑΓΝΩΡΙΣΙΜΟ - Ειδοποιήσεις Υπηρεσίας Meshtastic - Πρέπει να ενεργοποιήσετε τις υπηρεσίες εντοπισμού τοποθεσίας στις ρυθμίσεις Android - Σχετικά - Λίστα κόμβων στο mesh - Μηνύματα - Αυτό το κανάλι URL δεν είναι ορθό και δεν μπορεί να χρησιμοποιηθεί diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index bdaa4ffea..297425da4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -45,7 +45,7 @@ Compartir Desconectado Dispositivo en reposo - Conectado: %s de %s en línea + Conectado: %1$s de %2$s en línea Una lista de nodos en la red Actualizar el firmware Conectado a la radio diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 98dc00114..14b590c53 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -49,7 +49,7 @@ Partager Déconnecté Appareil en veille - Connecté: %s sur %s en ligne + Connecté: %1$s sur %2$s en ligne Une liste de nœuds dans le réseau Mise à jour du Firmware Connecté à une radio diff --git a/app/src/main/res/values-ht/strings.xml b/app/src/main/res/values-ht/strings.xml index 8c02da04e..9f1fa4f71 100644 --- a/app/src/main/res/values-ht/strings.xml +++ b/app/src/main/res/values-ht/strings.xml @@ -46,7 +46,7 @@ Pataje Dekonekte Aparèy ap dòmi - Konekte: %s nan %s disponib + Konekte: %1$s nan %2$s disponib Yon lis ne elektwonik nan rezo a Mete ajou mikrolojisyèl Konekte ak radyo diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 2b567d09c..ab0c66d30 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -47,7 +47,7 @@ Megosztás Szétkapcsolva Az eszköz alszik - Kapcsolódva: %s a %s-ból(ből) elérhető + Kapcsolódva: %1$s a %2$s-ból(ből) elérhető Hálózati állomások listája Firmware frissítés Kapcsolódva a rádióhoz diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index bc1d039f6..b38ebb5aa 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -48,7 +48,7 @@ mapboxの有償プラン(または代替地図プロバイダ)を検討さ シェア 切断 スリープ - 接続済み:%s人オンライン%s人中 + 接続済み:%1$s人オンライン%2$s人中 ネットワーク内のノードリスト ファームウェアアップデート Meshtasticデバイスに接続しました。(%s) diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index b624cf4a3..cad8b33c9 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -46,7 +46,7 @@ 공유 연결 해제 장치 잠자기 - 연결: %s 온라인( 전체 %s) + 연결: %1$s 온라인( 전체 %2$s) 네트워크안은 모든 노드의 목록 펌웨어 업데이트 라디오로 연결됨 diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 3df9b39ed..fb4323827 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -48,7 +48,7 @@ Deel Niet verbonden Apparaat in slaapstand - Verbonden: %s van %s online + Verbonden: %1$s van %2$s online Een lijst van de aansluitpunten in het netwerk Programma Updaten Verbonden met een radio diff --git a/app/src/main/res/values-no/strings.xml b/app/src/main/res/values-no/strings.xml index e17082877..437e1d080 100644 --- a/app/src/main/res/values-no/strings.xml +++ b/app/src/main/res/values-no/strings.xml @@ -48,7 +48,7 @@ Del Frakoblet Enhet sover - Tilkoblet: %s av %s på nett + Tilkoblet: %1$s av %2$s på nett En liste over noder i nettverket Oppdater Firmware Tilkoblet radio diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d5e0e74c0..5b9bb650e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -53,7 +53,7 @@ Udostępnij Rozłączone Urządzenie uśpione. - Połączono: %s of %s online + Połączono: %1$s of %2$s online Lista użytkowników w sieci Aktualizuj oprogramowanie. Połączony z urządzeniem diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 985b0106e..b2544e4e2 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -48,7 +48,7 @@ Compartilhar Desconectado Dispositivo em suspensão (sleep) - Conectado: %s de %s online + Conectado: %1$s de %2$s online Lista de dispositivos na rede Atualizar Firmware Conectado ao rádio diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index fc4bdd4bd..5a60314ce 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -47,7 +47,7 @@ Partilha Desconectado Dispositivo a dormir - Conectado: %s de %s online + Conectado: %1$s de %2$s online Lista de nós na rede Atualizar Firmware Conectado ao rádio diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index ca6d109dd..bd51f8773 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -48,7 +48,7 @@ Distribuie Deconectat Dispozitiv în sleep mode - Connectat: %s din %s online + Connectat: %1$s din %2$s online O lista cu nodurile din rețea Updateaza firmware-ul Connectat la dispozitiv diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 43e8cc17e..cbeabe0a3 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -48,7 +48,7 @@ Zdieľať Odpojené Vysielač uspatý - Pripojený: %s z %s je online + Pripojený: %1$s z %2$s je online Zoznam vysielačov v sieti Aktualizácia firmvéru Pripojené k vysielaču diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index aaffb5498..a5fc7d495 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -46,7 +46,7 @@ Deliti Prekinjeno Naprava je v "spanju" - Povezano: %s od %s je na mreži + Povezano: %1$s od %2$s je na mreži Seznam vozlišč v omrežju Posodobite vdelano programsko opremo Povezana z radiem diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 811ecab89..3d2b68d3e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -48,7 +48,7 @@ Paylaş Bağlantı sonlandı Cihaz uyku durumunda - Bağlandı: %s / %s online + Bağlandı: %1$s / %2$s online Ağdaki node listesi Yazılım güncelle Radyoya bağlandı diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 37137fb43..a456fb349 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -48,7 +48,7 @@ 分享 断开连接 设备休眠中 - 连接: %s 中 %s 在线 + 连接: %1$s 中 %2$s 在线 网络中节点列表 更新固件 连接设备 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 58b289b36..38c4573a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -52,7 +52,7 @@ Share Disconnected Device sleeping - Connected: %s of %s online + Connected: %1$s of %2$s online A list of nodes in the network Update Firmware Connected to radio diff --git a/build.gradle b/build.gradle index c4e902af7..49310ac9f 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ buildscript { ext.kotlin_version = '1.6.10' - ext.coroutines_version = "1.5.2" + ext.coroutines_version = "1.6.0" ext.hilt_version = '2.40.5' repositories { @@ -10,7 +10,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.1.1' + classpath 'com.android.tools.build:gradle:7.1.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"