mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-02-06 13:53:02 -05:00
refactor: extract Bluetooth and USB API methods to repositories
This commit is contained in:
@@ -3,13 +3,13 @@ package com.geeksville.mesh.model
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.le.*
|
||||
import android.companion.AssociationRequest
|
||||
import android.companion.BluetoothDeviceFilter
|
||||
import android.companion.CompanionDeviceManager
|
||||
import android.content.*
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.RemoteException
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
@@ -19,14 +19,17 @@ import com.geeksville.mesh.android.Logging
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.android.*
|
||||
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
|
||||
import com.geeksville.mesh.repository.nsd.NsdRepository
|
||||
import com.geeksville.mesh.repository.network.NetworkRepository
|
||||
import com.geeksville.mesh.repository.radio.InterfaceId
|
||||
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
||||
import com.geeksville.mesh.repository.usb.UsbRepository
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.geeksville.mesh.service.ServiceRepository
|
||||
import com.geeksville.mesh.util.anonymize
|
||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -36,16 +39,16 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class BTScanModel @Inject constructor(
|
||||
private val application: Application,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val usbRepository: UsbRepository,
|
||||
private val nsdRepository: NsdRepository,
|
||||
private val usbManagerLazy: dagger.Lazy<UsbManager>,
|
||||
private val networkRepository: NetworkRepository,
|
||||
private val radioInterfaceService: RadioInterfaceService,
|
||||
) : ViewModel(), Logging {
|
||||
|
||||
private val context: Context get() = application.applicationContext
|
||||
val devices = MutableLiveData<MutableMap<String, DeviceListEntry>>(mutableMapOf())
|
||||
private val bleDevices = MutableLiveData<List<BluetoothDevice>>(listOf())
|
||||
private val usbDevices = MutableLiveData<Map<String, UsbSerialDriver>>(mapOf())
|
||||
|
||||
val isMockInterfaceAddressValid: Boolean by lazy {
|
||||
radioInterfaceService.isAddressValid(radioInterfaceService.mockInterfaceAddress)
|
||||
@@ -54,11 +57,34 @@ class BTScanModel @Inject constructor(
|
||||
init {
|
||||
combine(
|
||||
bluetoothRepository.state,
|
||||
networkRepository.resolvedList,
|
||||
usbRepository.serialDevicesWithDrivers
|
||||
) { ble, usb ->
|
||||
bleDevices.value = ble.bondedDevices
|
||||
usbDevices.value = usb
|
||||
}.onEach { setupScan() }.launchIn(viewModelScope)
|
||||
) { ble, tcp, usb ->
|
||||
devices.value = mutableMapOf<String, DeviceListEntry>().apply {
|
||||
fun addDevice(entry: DeviceListEntry) { this[entry.fullAddress] = entry }
|
||||
|
||||
// Include a placeholder for "None"
|
||||
addDevice(DeviceListEntry(context.getString(R.string.none), "n", true))
|
||||
|
||||
if (isMockInterfaceAddressValid) {
|
||||
addDevice(DeviceListEntry("Included simulator", "m", true))
|
||||
}
|
||||
|
||||
// Include paired Bluetooth devices
|
||||
ble.bondedDevices.forEach {
|
||||
addDevice(BLEDeviceListEntry(it))
|
||||
}
|
||||
|
||||
// Include Network Service Discovery
|
||||
tcp.forEach { service ->
|
||||
addDevice(TCPDeviceListEntry(service))
|
||||
}
|
||||
|
||||
usb.forEach { (_, d) ->
|
||||
addDevice(USBDeviceListEntry(radioInterfaceService, usbManagerLazy.get(), d))
|
||||
}
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
|
||||
debug("BTScanModel created")
|
||||
}
|
||||
@@ -107,136 +133,49 @@ class BTScanModel @Inject constructor(
|
||||
debug("BTScanModel cleared")
|
||||
}
|
||||
|
||||
var selectedAddress: String? = null
|
||||
val errorText = MutableLiveData<String?>(null)
|
||||
|
||||
fun setErrorText(text: String) {
|
||||
errorText.value = text
|
||||
}
|
||||
|
||||
private var scanner: BluetoothLeScanner? = null
|
||||
private var scanJob: Job? = null
|
||||
|
||||
val selectedAddress get() = radioInterfaceService.getDeviceAddress()
|
||||
val selectedBluetooth: Boolean get() = selectedAddress?.getOrNull(0) == 'x'
|
||||
|
||||
/// Use the string for the NopInterface
|
||||
val selectedNotNull: String get() = selectedAddress ?: "n"
|
||||
|
||||
private val scanCallback = object : ScanCallback() {
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
val msg = "Unexpected bluetooth scan failure: $errorCode"
|
||||
errormsg(msg)
|
||||
// error code2 seems to be indicate hung bluetooth stack
|
||||
errorText.value = msg
|
||||
}
|
||||
|
||||
// For each device that appears in our scan, ask for its GATT, when the gatt arrives,
|
||||
// check if it is an eligible device and store it in our list of candidates
|
||||
// if that device later disconnects remove it as a candidate
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
|
||||
if (result.device.name.let { it != null && it.matches(Regex(BLE_NAME_PATTERN)) }) {
|
||||
val addr = result.device.address
|
||||
val fullAddr = "x$addr" // full address with the bluetooth prefix added
|
||||
// prevent log spam because we'll get lots of redundant scan results
|
||||
val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED
|
||||
val oldDevs = devices.value!!
|
||||
val oldEntry = oldDevs[fullAddr]
|
||||
if (oldEntry == null || oldEntry.bonded != isBonded) { // Don't spam the GUI with endless updates for non changing nodes
|
||||
val entry = DeviceListEntry(
|
||||
result.device.name,
|
||||
fullAddr,
|
||||
isBonded
|
||||
)
|
||||
addDevice(entry) // Add/replace entry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addDevice(entry: DeviceListEntry) {
|
||||
val oldDevs = devices.value!!
|
||||
oldDevs[entry.fullAddress] = entry // Add/replace entry
|
||||
devices.value = oldDevs // trigger gui updates
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun stopScan() {
|
||||
// Stop Network Service Discovery (for TCP)
|
||||
networkDiscovery?.cancel()
|
||||
|
||||
if (scanner != null) {
|
||||
if (scanJob != null) {
|
||||
debug("stopping scan")
|
||||
try {
|
||||
scanner?.stopScan(scanCallback)
|
||||
scanJob?.cancel()
|
||||
} catch (ex: Throwable) {
|
||||
warn("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}")
|
||||
} finally {
|
||||
scanner = null
|
||||
scanJob = null
|
||||
_spinner.value = false
|
||||
}
|
||||
} else _spinner.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* returns true if we could start scanning, false otherwise
|
||||
*/
|
||||
private fun setupScan(): Boolean {
|
||||
selectedAddress = radioInterfaceService.getDeviceAddress()
|
||||
|
||||
return if (isMockInterfaceAddressValid) {
|
||||
warn("Running under emulator/test lab")
|
||||
|
||||
listOf(
|
||||
DeviceListEntry(context.getString(R.string.none), "n", true),
|
||||
DeviceListEntry("Included simulator", "m", true),
|
||||
/* Don't populate fake bluetooth devices, because we don't want testlab inside of google
|
||||
to try and use them.
|
||||
|
||||
DeviceListEntry("Meshtastic_ab12", "xaa", false),
|
||||
DeviceListEntry("Meshtastic_32ac", "xbb", true) */
|
||||
).forEach(::addDevice)
|
||||
true
|
||||
} else {
|
||||
if (scanner == null) {
|
||||
val newDevs = mutableMapOf<String, DeviceListEntry>()
|
||||
|
||||
fun addDevice(entry: DeviceListEntry) {
|
||||
newDevs[entry.fullAddress] = entry
|
||||
}
|
||||
|
||||
// Include a placeholder for "None"
|
||||
addDevice(DeviceListEntry(context.getString(R.string.none), "n", true))
|
||||
|
||||
// Include paired Bluetooth devices
|
||||
bleDevices.value?.forEach {
|
||||
addDevice(BLEDeviceListEntry(it))
|
||||
}
|
||||
|
||||
// Include Network Service Discovery
|
||||
nsdRepository.resolvedList.value.forEach { service ->
|
||||
addDevice(TCPDeviceListEntry(service))
|
||||
}
|
||||
|
||||
usbDevices.value?.forEach { (_, d) ->
|
||||
addDevice(USBDeviceListEntry(radioInterfaceService, context.usbManager, d))
|
||||
}
|
||||
|
||||
devices.value = newDevs
|
||||
} else {
|
||||
debug("scan already running")
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private var networkDiscovery: Job? = null
|
||||
fun startScan(context: Context?) {
|
||||
_spinner.value = true
|
||||
|
||||
// Start Network Service Discovery (find TCP devices)
|
||||
networkDiscovery = nsdRepository.networkDiscoveryFlow()
|
||||
.onEach { addDevice(TCPDeviceListEntry(it)) }
|
||||
networkDiscovery = networkRepository.networkDiscoveryFlow()
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
if (context != null) startCompanionScan(context) else startClassicScan()
|
||||
@@ -244,43 +183,95 @@ class BTScanModel @Inject constructor(
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startClassicScan() {
|
||||
/// The following call might return null if the user doesn't have bluetooth access permissions
|
||||
val bluetoothLeScanner = bluetoothRepository.getBluetoothLeScanner()
|
||||
debug("starting classic scan")
|
||||
|
||||
if (bluetoothLeScanner != null) { // could be null if bluetooth is disabled
|
||||
debug("starting classic scan")
|
||||
scanJob = bluetoothRepository.scan()
|
||||
.onEach { result ->
|
||||
val fullAddress = radioInterfaceService.toInterfaceAddress(
|
||||
InterfaceId.BLUETOOTH,
|
||||
result.device.address
|
||||
)
|
||||
// prevent log spam because we'll get lots of redundant scan results
|
||||
val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED
|
||||
val oldDevs = devices.value ?: emptyMap()
|
||||
val oldEntry = oldDevs[fullAddress]
|
||||
// Don't spam the GUI with endless updates for non changing nodes
|
||||
if (oldEntry == null || oldEntry.bonded != isBonded) {
|
||||
val entry = DeviceListEntry(result.device.name, fullAddress, isBonded)
|
||||
addDevice(entry)
|
||||
}
|
||||
}.catch { ex ->
|
||||
radioInterfaceService.setErrorMessage("Unexpected Bluetooth scan failure: ${ex.message}")
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
// filter and only accept devices that have our service
|
||||
val filter =
|
||||
ScanFilter.Builder()
|
||||
// Samsung doesn't seem to filter properly by service so this can't work
|
||||
// see https://stackoverflow.com/questions/57981986/altbeacon-android-beacon-library-not-working-after-device-has-screen-off-for-a-s/57995960#57995960
|
||||
// and https://stackoverflow.com/a/45590493
|
||||
// .setServiceUuid(ParcelUuid(BluetoothInterface.BTM_SERVICE_UUID))
|
||||
.build()
|
||||
|
||||
val settings =
|
||||
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback)
|
||||
scanner = bluetoothLeScanner
|
||||
private fun changeDeviceAddress(address: String) {
|
||||
try {
|
||||
serviceRepository.meshService?.let { service ->
|
||||
MeshService.changeDeviceAddress(context, service, address)
|
||||
}
|
||||
devices.value = devices.value // Force a GUI update
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("changeDeviceSelection failed, probably it is shutting down", ex)
|
||||
// ignore the failure and the GUI won't be updating anyways
|
||||
}
|
||||
}
|
||||
|
||||
fun getRemoteDevice(address: String) = bluetoothRepository.getRemoteDevice(address)
|
||||
|
||||
/**
|
||||
* @return DeviceListEntry from full Address (prefix + address).
|
||||
* If Bluetooth is enabled and BLE Address is valid, get remote device information.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun getDeviceListEntry(fullAddress: String, bonded: Boolean = false): DeviceListEntry {
|
||||
val address = fullAddress.substring(1)
|
||||
val device = getRemoteDevice(address)
|
||||
return if (device != null && device.name != null) {
|
||||
BLEDeviceListEntry(device)
|
||||
private fun requestBonding(it: DeviceListEntry) {
|
||||
val device = bluetoothRepository.getRemoteDevice(it.address) ?: return
|
||||
info("Starting bonding for ${device.anonymize}")
|
||||
|
||||
bluetoothRepository.createBond(device)
|
||||
.onEach { state ->
|
||||
debug("Received bond state changed $state")
|
||||
if (state != BluetoothDevice.BOND_BONDING) {
|
||||
debug("Bonding completed, state=$state")
|
||||
if (state == BluetoothDevice.BOND_BONDED) {
|
||||
setErrorText(context.getString(R.string.pairing_completed))
|
||||
changeDeviceAddress(it.fullAddress)
|
||||
} else {
|
||||
setErrorText(context.getString(R.string.pairing_failed_try_again))
|
||||
}
|
||||
}
|
||||
}.catch { ex ->
|
||||
// We ignore missing BT adapters, because it lets us run on the emulator
|
||||
warn("Failed creating Bluetooth bond: ${ex.message}")
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun requestPermission(it: USBDeviceListEntry) {
|
||||
usbRepository.requestPermission(it.usb.device)
|
||||
.onEach { granted ->
|
||||
if (granted) {
|
||||
info("User approved USB access")
|
||||
changeDeviceAddress(it.fullAddress)
|
||||
} else {
|
||||
errormsg("USB permission denied for device ${it.address}")
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
// Called by the GUI when a new device has been selected by the user
|
||||
// @returns true if we were able to change to that item
|
||||
fun onSelected(it: DeviceListEntry): Boolean {
|
||||
// If the device is paired, let user select it, otherwise start the pairing flow
|
||||
if (it.bonded) {
|
||||
changeDeviceAddress(it.fullAddress)
|
||||
return true
|
||||
} else {
|
||||
DeviceListEntry(address, fullAddress, bonded)
|
||||
// Handle requesting USB or bluetooth permissions for the device
|
||||
debug("Requesting permissions for the device")
|
||||
|
||||
if (it.isBLE) {
|
||||
requestBonding(it)
|
||||
}
|
||||
|
||||
if (it.isUSB) {
|
||||
requestPermission(it as USBDeviceListEntry)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +313,6 @@ class BTScanModel @Inject constructor(
|
||||
debug("starting companion scan")
|
||||
context.companionDeviceManager?.associate(
|
||||
associationRequest(),
|
||||
@SuppressLint("NewApi")
|
||||
object : CompanionDeviceManager.Callback() {
|
||||
@Deprecated("Deprecated in Java", ReplaceWith("onAssociationPending(intentSender)"))
|
||||
override fun onDeviceFound(intentSender: IntentSender) {
|
||||
@@ -345,16 +335,7 @@ class BTScanModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after activity calls MeshService.changeDeviceAddress
|
||||
*/
|
||||
fun changeSelectedAddress(newAddress: String) {
|
||||
selectedAddress = newAddress
|
||||
devices.value = devices.value // Force a GUI update
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BLE_NAME_PATTERN = BluetoothRepository.BLE_NAME_PATTERN
|
||||
const val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.geeksville.mesh.repository.bluetooth
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import androidx.annotation.RequiresPermission
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
|
||||
@RequiresPermission("android.permission.BLUETOOTH_CONNECT")
|
||||
internal fun BluetoothDevice.createBond(context: Context): Flow<Int> = callbackFlow {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1)
|
||||
trySend(state)
|
||||
|
||||
// we stay registered until bonding completes (either with BONDED or NONE)
|
||||
if (state != BluetoothDevice.BOND_BONDING) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
|
||||
context.registerReceiver(receiver, filter)
|
||||
createBond()
|
||||
|
||||
awaitClose { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.geeksville.mesh.repository.bluetooth
|
||||
|
||||
import android.bluetooth.le.BluetoothLeScanner
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import androidx.annotation.RequiresPermission
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
|
||||
@RequiresPermission("android.permission.BLUETOOTH_SCAN")
|
||||
internal fun BluetoothLeScanner.scan(
|
||||
filters: List<ScanFilter> = emptyList(),
|
||||
scanSettings: ScanSettings = ScanSettings.Builder().build(),
|
||||
): Flow<ScanResult> = callbackFlow {
|
||||
val callback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
trySend(result)
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
cancel("onScanFailed() called with errorCode: $errorCode")
|
||||
}
|
||||
}
|
||||
startScan(filters, scanSettings, callback)
|
||||
|
||||
awaitClose { stopScan(callback) }
|
||||
}
|
||||
@@ -5,12 +5,21 @@ import android.app.Application
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.le.BluetoothLeScanner
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import com.geeksville.mesh.android.Logging
|
||||
import com.geeksville.mesh.CoroutineDispatchers
|
||||
import com.geeksville.mesh.android.hasBluetoothPermission
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -59,12 +68,32 @@ class BluetoothRepository @Inject constructor(
|
||||
?.getRemoteDevice(address)
|
||||
}
|
||||
|
||||
fun getBluetoothLeScanner(): BluetoothLeScanner? {
|
||||
private fun getBluetoothLeScanner(): BluetoothLeScanner? {
|
||||
return bluetoothAdapterLazy.get()
|
||||
?.takeIf { application.hasBluetoothPermission() }
|
||||
?.bluetoothLeScanner
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun scan(): Flow<ScanResult> {
|
||||
val filter = ScanFilter.Builder()
|
||||
// Samsung doesn't seem to filter properly by service so this can't work
|
||||
// see https://stackoverflow.com/questions/57981986/altbeacon-android-beacon-library-not-working-after-device-has-screen-off-for-a-s/57995960#57995960
|
||||
// and https://stackoverflow.com/a/45590493
|
||||
// .setServiceUuid(ParcelUuid(BluetoothInterface.BTM_SERVICE_UUID))
|
||||
.build()
|
||||
|
||||
val settings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
|
||||
return getBluetoothLeScanner()?.scan(listOf(filter), settings)
|
||||
?.filter { it.device.name?.matches(Regex(BLE_NAME_PATTERN)) == true } ?: emptyFlow()
|
||||
}
|
||||
|
||||
@RequiresPermission("android.permission.BLUETOOTH_CONNECT")
|
||||
fun createBond(device: BluetoothDevice): Flow<Int> = device.createBond(application)
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
internal suspend fun updateBluetoothState() {
|
||||
val newState: BluetoothState = bluetoothAdapterLazy.get()?.takeIf {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.geeksville.mesh.repository.nsd
|
||||
package com.geeksville.mesh.repository.network
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
@@ -6,21 +6,18 @@ import android.net.NetworkRequest
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import com.geeksville.mesh.android.Logging
|
||||
import com.geeksville.mesh.CoroutineDispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NsdRepository @Inject constructor(
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
class NetworkRepository @Inject constructor(
|
||||
private val nsdManagerLazy: dagger.Lazy<NsdManager?>,
|
||||
private val connectivityManager: dagger.Lazy<ConnectivityManager>,
|
||||
) : Logging {
|
||||
@@ -43,24 +40,20 @@ class NsdRepository @Inject constructor(
|
||||
awaitClose { connectivityManager.get().unregisterNetworkCallback(callback) }
|
||||
}
|
||||
|
||||
private val resolveQueue = Semaphore(1)
|
||||
private val hostsList = mutableListOf<NsdServiceInfo>()
|
||||
|
||||
private val _resolvedList = MutableStateFlow<List<NsdServiceInfo>>(emptyList())
|
||||
val resolvedList: StateFlow<List<NsdServiceInfo>> get() = _resolvedList
|
||||
|
||||
private val _networkDiscovery: Flow<NsdServiceInfo> = callbackFlow {
|
||||
val resolveQueue = Semaphore(1)
|
||||
val hostsList = mutableListOf<NsdServiceInfo>()
|
||||
val discoveryListener = object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(regType: String) {
|
||||
debug("Service discovery started: $regType")
|
||||
hostsList.clear()
|
||||
override fun onDiscoveryStarted(serviceType: String) {
|
||||
debug("Service discovery started: $serviceType")
|
||||
}
|
||||
|
||||
override fun onServiceFound(service: NsdServiceInfo) {
|
||||
debug("Service discovery success: $service")
|
||||
if (service.serviceType == SERVICE_TYPE &&
|
||||
service.serviceName.contains(serviceName)
|
||||
) {
|
||||
if (service.serviceName.contains(SERVICE_NAME)) {
|
||||
val resolveListener = object : NsdManager.ResolveListener {
|
||||
override fun onServiceResolved(service: NsdServiceInfo) {
|
||||
debug("Resolve Succeeded: $service")
|
||||
@@ -97,26 +90,24 @@ class NsdRepository @Inject constructor(
|
||||
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
debug("Start Discovery failed: Error code: $errorCode")
|
||||
nsdManagerLazy.get()?.stopServiceDiscovery(this)
|
||||
}
|
||||
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
debug("Stop Discovery failed: Error code: $errorCode")
|
||||
nsdManagerLazy.get()?.stopServiceDiscovery(this)
|
||||
}
|
||||
}
|
||||
nsdManagerLazy.get()
|
||||
?.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
||||
awaitClose { nsdManagerLazy.get()?.stopServiceDiscovery(discoveryListener) }
|
||||
}.flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
fun networkDiscoveryFlow(): Flow<NsdServiceInfo> {
|
||||
return _networkDiscovery
|
||||
}
|
||||
|
||||
companion object {
|
||||
//To find all the available networks SERVICE_TYPE = "_services._dns-sd._udp"
|
||||
const val SERVICE_TYPE = "_https._tcp."
|
||||
const val serviceName = "Meshtastic"
|
||||
// To find all available services use SERVICE_TYPE = "_services._dns-sd._udp"
|
||||
internal const val SERVICE_NAME = "Meshtastic"
|
||||
internal const val SERVICE_TYPE = "_https._tcp."
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.geeksville.mesh.repository.nsd
|
||||
package com.geeksville.mesh.repository.network
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
@@ -11,7 +11,7 @@ import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class NsdRepositoryModule {
|
||||
class NetworkRepositoryModule {
|
||||
companion object {
|
||||
@Provides
|
||||
fun provideConnectivityManager(application: Application): ConnectivityManager {
|
||||
@@ -11,7 +11,7 @@ import com.geeksville.mesh.android.GeeksvilleApplication
|
||||
import com.geeksville.mesh.android.Logging
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
|
||||
import com.geeksville.mesh.repository.nsd.NsdRepository
|
||||
import com.geeksville.mesh.repository.network.NetworkRepository
|
||||
import com.geeksville.mesh.util.anonymize
|
||||
import com.geeksville.mesh.util.ignoreException
|
||||
import com.geeksville.mesh.util.toRemoteExceptions
|
||||
@@ -44,7 +44,7 @@ class RadioInterfaceService @Inject constructor(
|
||||
private val context: Application,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
bluetoothRepository: BluetoothRepository,
|
||||
nsdRepository: NsdRepository,
|
||||
networkRepository: NetworkRepository,
|
||||
private val processLifecycle: Lifecycle,
|
||||
@RadioRepositoryQualifier private val prefs: SharedPreferences,
|
||||
private val interfaceFactory: InterfaceFactory,
|
||||
@@ -99,7 +99,7 @@ class RadioInterfaceService @Inject constructor(
|
||||
else if (radioIf is BluetoothInterface) stopInterface()
|
||||
}.launchIn(processLifecycle.coroutineScope)
|
||||
|
||||
nsdRepository.networkAvailable.onEach { state ->
|
||||
networkRepository.networkAvailable.onEach { state ->
|
||||
if (state) startInterface()
|
||||
else if (radioIf is TCPInterface) stopInterface()
|
||||
}.launchIn(processLifecycle.coroutineScope)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.geeksville.mesh.repository.radio
|
||||
|
||||
import android.app.Application
|
||||
import com.geeksville.mesh.android.usbManager
|
||||
import android.hardware.usb.UsbManager
|
||||
import com.geeksville.mesh.repository.usb.UsbRepository
|
||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import javax.inject.Inject
|
||||
@@ -11,7 +10,7 @@ import javax.inject.Inject
|
||||
*/
|
||||
class SerialInterfaceSpec @Inject constructor(
|
||||
private val factory: SerialInterfaceFactory,
|
||||
private val context: Application,
|
||||
private val usbManager: dagger.Lazy<UsbManager>,
|
||||
private val usbRepository: UsbRepository,
|
||||
): InterfaceSpec<SerialInterface> {
|
||||
override fun createInterface(rest: String): SerialInterface {
|
||||
@@ -22,10 +21,10 @@ class SerialInterfaceSpec @Inject constructor(
|
||||
rest: String
|
||||
): Boolean {
|
||||
usbRepository.serialDevicesWithDrivers.value.filterValues {
|
||||
context.usbManager.hasPermission(it.device)
|
||||
usbManager.get().hasPermission(it.device)
|
||||
}
|
||||
findSerial(rest)?.let { d ->
|
||||
return context.usbManager.hasPermission(d.device)
|
||||
return usbManager.get().hasPermission(d.device)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.geeksville.mesh.repository.usb
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbManager
|
||||
import com.geeksville.mesh.util.PendingIntentCompat
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
|
||||
private const val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION"
|
||||
|
||||
internal fun UsbManager.requestPermission(
|
||||
context: Context,
|
||||
device: UsbDevice,
|
||||
): Flow<Boolean> = callbackFlow {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
||||
trySend(granted)
|
||||
close()
|
||||
}
|
||||
}
|
||||
val permissionIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
Intent(ACTION_USB_PERMISSION),
|
||||
PendingIntentCompat.FLAG_MUTABLE
|
||||
)
|
||||
val filter = IntentFilter(ACTION_USB_PERMISSION)
|
||||
context.registerReceiver(receiver, filter)
|
||||
requestPermission(device, permissionIntent)
|
||||
|
||||
awaitClose { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
@@ -75,6 +75,9 @@ class UsbRepository @Inject constructor(
|
||||
return SerialConnectionImpl(usbManagerLazy, device, listener)
|
||||
}
|
||||
|
||||
fun requestPermission(device: UsbDevice): Flow<Boolean> =
|
||||
usbManagerLazy.get()?.requestPermission(application, device) ?: emptyFlow()
|
||||
|
||||
fun refreshState() {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) {
|
||||
refreshStateInternal()
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
package com.geeksville.mesh.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.companion.CompanionDeviceManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.RemoteException
|
||||
import android.util.Patterns
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@@ -24,12 +20,8 @@ import android.widget.RadioButton
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.geeksville.mesh.ConfigProtos
|
||||
import com.geeksville.mesh.MainActivity
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.analytics.DataPair
|
||||
import com.geeksville.mesh.ModuleConfigProtos
|
||||
@@ -42,15 +34,11 @@ import com.geeksville.mesh.model.getInitials
|
||||
import com.geeksville.mesh.repository.location.LocationRepository
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.geeksville.mesh.service.SoftwareUpdateService
|
||||
import com.geeksville.mesh.util.PendingIntentCompat
|
||||
import com.geeksville.mesh.util.anonymize
|
||||
import com.geeksville.mesh.util.exceptionReporter
|
||||
import com.geeksville.mesh.util.exceptionToSnackbar
|
||||
import com.geeksville.mesh.util.getParcelableExtraCompat
|
||||
import com.geeksville.mesh.util.onEditorAction
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -67,8 +55,6 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
@Inject
|
||||
internal lateinit var locationRepository: LocationRepository
|
||||
|
||||
private val myActivity get() = requireActivity() as MainActivity
|
||||
|
||||
private val hasGps by lazy { requireContext().hasGps() }
|
||||
private val hasCompanionDeviceApi by lazy { requireContext().hasCompanionDeviceApi() }
|
||||
private val useCompanionDeviceApi by lazy {
|
||||
@@ -239,14 +225,12 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
) {
|
||||
it.data
|
||||
?.getParcelableExtraCompat<BluetoothDevice>(CompanionDeviceManager.EXTRA_DEVICE)
|
||||
?.let { device -> onSelected(BTScanModel.BLEDeviceListEntry(device)) }
|
||||
?.let { device -> scanModel.onSelected(BTScanModel.BLEDeviceListEntry(device)) }
|
||||
}
|
||||
|
||||
val requestBackgroundAndCheckLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
|
||||
if (permissions.entries.all { it.value }) {
|
||||
binding.provideLocationCheckbox.isChecked = true
|
||||
} else {
|
||||
if (permissions.entries.any { !it.value }) {
|
||||
debug("User denied background permission")
|
||||
model.showSnackbar(getString(R.string.why_background_required))
|
||||
}
|
||||
@@ -256,9 +240,8 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
|
||||
if (permissions.entries.all { it.value }) {
|
||||
// Older versions of android only need Location permission
|
||||
if (requireContext().hasBackgroundPermission()) {
|
||||
binding.provideLocationCheckbox.isChecked = true
|
||||
} else requestBackgroundAndCheckLauncher.launch(requireContext().getBackgroundPermissions())
|
||||
if (!requireContext().hasBackgroundPermission())
|
||||
requestBackgroundAndCheckLauncher.launch(requireContext().getBackgroundPermissions())
|
||||
} else {
|
||||
debug("User denied location permission")
|
||||
model.showSnackbar(getString(R.string.why_background_required))
|
||||
@@ -357,12 +340,8 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
}
|
||||
|
||||
// Observe receivingLocationUpdates state and update provideLocationCheckbox
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
locationRepository.receivingLocationUpdates.collect {
|
||||
binding.provideLocationCheckbox.isChecked = it
|
||||
}
|
||||
}
|
||||
locationRepository.receivingLocationUpdates.asLiveData().observe(viewLifecycleOwner) {
|
||||
binding.provideLocationCheckbox.isChecked = it
|
||||
}
|
||||
|
||||
binding.provideLocationCheckbox.setOnCheckedChangeListener { view, isChecked ->
|
||||
@@ -439,8 +418,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
b.setOnClickListener {
|
||||
if (!device.bonded) // If user just clicked on us, try to bond
|
||||
binding.scanStatusText.setText(R.string.starting_pairing)
|
||||
|
||||
b.isChecked = onSelected(device)
|
||||
b.isChecked = scanModel.onSelected(device)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,12 +430,8 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
|
||||
binding.deviceRadioGroup.addView(b)
|
||||
|
||||
|
||||
b.setOnClickListener {
|
||||
|
||||
|
||||
b.isChecked = onSelected(BTScanModel.DeviceListEntry("", "t" + e.text, true))
|
||||
|
||||
b.isChecked = scanModel.onSelected(BTScanModel.DeviceListEntry("", "t" + e.text, true))
|
||||
}
|
||||
binding.deviceRadioGroup.addView(e)
|
||||
e.doAfterTextChanged {
|
||||
@@ -486,7 +460,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
// and before use
|
||||
val curAddr = scanModel.selectedAddress
|
||||
if (curAddr != null) {
|
||||
val curDevice = scanModel.getDeviceListEntry(curAddr)
|
||||
val curDevice = BTScanModel.DeviceListEntry(curAddr.substring(1), curAddr, false)
|
||||
addDeviceButton(curDevice, model.isConnected())
|
||||
}
|
||||
}
|
||||
@@ -522,115 +496,6 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeDeviceAddress(address: String) {
|
||||
try {
|
||||
model.meshService?.let { service ->
|
||||
MeshService.changeDeviceAddress(requireActivity(), service, address)
|
||||
}
|
||||
scanModel.changeSelectedAddress(address) // if it throws the change will be discarded
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("changeDeviceSelection failed, probably it is shutting down $ex.message")
|
||||
// ignore the failure and the GUI won't be updating anyways
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by the GUI when a new device has been selected by the user
|
||||
/// Returns true if we were able to change to that item
|
||||
private fun onSelected(it: BTScanModel.DeviceListEntry): Boolean {
|
||||
// If the device is paired, let user select it, otherwise start the pairing flow
|
||||
if (it.bonded) {
|
||||
changeDeviceAddress(it.fullAddress)
|
||||
return true
|
||||
} else {
|
||||
// Handle requesting USB or bluetooth permissions for the device
|
||||
debug("Requesting permissions for the device")
|
||||
|
||||
exceptionReporter {
|
||||
if (it.isBLE) {
|
||||
// Request bonding for bluetooth
|
||||
// We ignore missing BT adapters, because it lets us run on the emulator
|
||||
scanModel.getRemoteDevice(it.address)?.let { device ->
|
||||
requestBonding(device) { state ->
|
||||
if (state == BluetoothDevice.BOND_BONDED) {
|
||||
scanModel.setErrorText(getString(R.string.pairing_completed))
|
||||
changeDeviceAddress(it.fullAddress)
|
||||
} else {
|
||||
scanModel.setErrorText(getString(R.string.pairing_failed_try_again))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (it.isUSB) {
|
||||
it as BTScanModel.USBDeviceListEntry
|
||||
|
||||
val usbReceiver = object : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (BTScanModel.ACTION_USB_PERMISSION != intent.action) return
|
||||
|
||||
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
|
||||
info("User approved USB access")
|
||||
changeDeviceAddress(it.fullAddress)
|
||||
} else {
|
||||
errormsg("USB permission denied for device ${it.address}")
|
||||
}
|
||||
// We don't need to stay registered
|
||||
requireActivity().unregisterReceiver(this)
|
||||
}
|
||||
}
|
||||
|
||||
val permissionIntent = PendingIntent.getBroadcast(
|
||||
activity,
|
||||
0,
|
||||
Intent(BTScanModel.ACTION_USB_PERMISSION),
|
||||
PendingIntentCompat.FLAG_MUTABLE
|
||||
)
|
||||
val filter = IntentFilter(BTScanModel.ACTION_USB_PERMISSION)
|
||||
requireActivity().registerReceiver(usbReceiver, filter)
|
||||
requireContext().usbManager.requestPermission(it.usb.device, permissionIntent)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the UI asking the user to bond with a device, call changeSelection() if/when bonding completes
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun requestBonding(
|
||||
device: BluetoothDevice,
|
||||
onComplete: (Int) -> Unit
|
||||
) {
|
||||
info("Starting bonding for ${device.anonymize}")
|
||||
|
||||
// We need this receiver to get informed when the bond attempt finished
|
||||
val bondChangedReceiver = object : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) = exceptionReporter {
|
||||
val state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1)
|
||||
debug("Received bond state changed $state")
|
||||
|
||||
if (state != BluetoothDevice.BOND_BONDING) {
|
||||
context.unregisterReceiver(this) // we stay registered until bonding completes (either with BONDED or NONE)
|
||||
debug("Bonding completed, state=$state")
|
||||
onComplete(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val filter = IntentFilter()
|
||||
filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
|
||||
requireActivity().registerReceiver(bondChangedReceiver, filter)
|
||||
|
||||
// We ignore missing BT adapters, because it lets us run on the emulator
|
||||
try {
|
||||
device.createBond()
|
||||
} catch (ex: Throwable) {
|
||||
warn("Failed creating Bluetooth bond: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
@@ -702,10 +567,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
// system permissions might have changed while we were away
|
||||
binding.provideLocationCheckbox.isChecked = requireContext().hasBackgroundPermission() && (model.provideLocation.value ?: false)
|
||||
|
||||
myActivity.registerReceiver(updateProgressReceiver, updateProgressFilter)
|
||||
requireActivity().registerReceiver(updateProgressReceiver, updateProgressFilter)
|
||||
|
||||
// Warn user if BLE device is selected but BLE disabled
|
||||
if (scanModel.selectedBluetooth) checkBTEnabled()
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package com.geeksville.mesh.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.companion.CompanionDeviceManager
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
|
||||
Reference in New Issue
Block a user