diff --git a/app/build.gradle b/app/build.gradle
index bbb20451c..70e4524ff 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -42,7 +42,7 @@ android {
defaultConfig {
applicationId "com.geeksville.mesh"
minSdkVersion 21 // The oldest emulator image I have tried is 22 (though 21 probably works)
- targetSdkVersion 30
+ targetSdkVersion 31
versionCode 30000 // format is Mmmss (where M is 1+the numeric major number
versionName "2.0.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 936141e97..a7334fbac 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,23 +11,21 @@
android:name="android.hardware.location.gps"
android:required="false" />
-
- <!– API 31+ Bluetooth permissions –>
+
--->
-
-
+ android:usesPermissionFlags="neverForLocation"
+ tools:targetApi="s" />
+
diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
index aa02eca0f..0cf59c45c 100644
--- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt
+++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
@@ -8,7 +8,10 @@ import android.content.pm.PackageManager
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.net.Uri
-import android.os.*
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.os.RemoteException
import android.text.method.LinkMovementMethod
import android.view.Menu
import android.view.MenuItem
@@ -19,20 +22,14 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.widget.Toolbar
-import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
import androidx.viewpager2.adapter.FragmentStateAdapter
-import com.geeksville.mesh.android.BindFailedException
-import com.geeksville.mesh.android.GeeksvilleApplication
-import com.geeksville.mesh.android.Logging
-import com.geeksville.mesh.android.ServiceClient
+import com.geeksville.mesh.android.*
import com.geeksville.mesh.concurrent.handledLaunch
-import com.geeksville.mesh.android.getMissingPermissions
-import com.geeksville.mesh.android.isGooglePlayAvailable
import com.geeksville.mesh.databinding.ActivityMainBinding
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.BluetoothViewModel
@@ -130,8 +127,9 @@ class MainActivity : BaseActivity(), Logging {
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (!permissions.entries.all { it.value }) {
errormsg("User denied permissions")
- showSnackbar(getString(R.string.permission_missing_31))
+ showSnackbar(permissionMissing)
}
+ requestedEnable = false
bluetoothViewModel.permissionsUpdated()
}
@@ -181,71 +179,6 @@ class MainActivity : BaseActivity(), Logging {
}
}
- /** Get the minimum permissions our app needs to run correctly
- */
- private fun getMinimumPermissions(): Array {
- val perms = mutableListOf()
-
- // We only need this for logging to capture files for the simulator - turn off for production
- // perms.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
-
-/* TODO - wait for targetSdkVersion 31
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- perms.add(Manifest.permission.BLUETOOTH_SCAN)
- perms.add(Manifest.permission.BLUETOOTH_CONNECT)
- }
-*/
- return getMissingPermissions(perms)
- }
-
- /** Possibly prompt user to grant permissions
- * @param shouldShowDialog usually true, but in cases where we've already shown a dialog elsewhere we skip it.
- *
- * @return true if we already have the needed permissions
- */
- private fun requestPermission(
- missingPerms: Array = getMinimumPermissions(),
- shouldShowDialog: Boolean = true
- ): Boolean =
- if (missingPerms.isNotEmpty()) {
- val shouldShow = missingPerms.filter {
- ActivityCompat.shouldShowRequestPermissionRationale(this, it)
- }
-
- fun doRequest() {
- info("requesting permissions")
- // Ask for all the missing perms
- requestPermissionsLauncher.launch(missingPerms)
- }
-
- if (shouldShow.isNotEmpty() && shouldShowDialog) {
- // DID_REQUEST_PERM is an
- // app-defined int constant. The callback method gets the
- // result of the request.
- warn("Permissions $shouldShow missing, we should show dialog")
-
- MaterialAlertDialogBuilder(this)
- .setTitle(getString(R.string.required_permissions))
- .setMessage(getString(R.string.permission_missing_31))
- .setNeutralButton(R.string.cancel) { _, _ ->
- warn("User bailed due to permissions")
- }
- .setPositiveButton(R.string.accept) { _, _ ->
- doRequest()
- }
- .show()
- } else {
- info("Permissions $missingPerms missing, no need to show dialog, just asking OS")
- doRequest()
- }
-
- false
- } else {
- // Permission has already been granted
- debug("We have our required permissions")
- true
- }
-
/// Ask user to rate in play store
private fun askToRate() {
exceptionReporter { // we don't want to crash our app because of bugs in this optional feature
@@ -296,9 +229,6 @@ class MainActivity : BaseActivity(), Logging {
handleIntent(intent)
if (isGooglePlayAvailable(this)) askToRate()
-
- // if (!isInTestLab) - very important - even in test lab we must request permissions because we need location perms for some of our tests to pass
- requestPermission()
}
private fun initToolbar() {
@@ -728,11 +658,22 @@ class MainActivity : BaseActivity(), Logging {
super.onStart()
bluetoothViewModel.enabled.observe(this) { enabled ->
- if (!enabled && !requestedEnable) {
- if (!isInTestLab && scanModel.selectedBluetooth) {
- requestedEnable = true
+ if (!enabled && !requestedEnable && scanModel.selectedBluetooth) {
+ requestedEnable = true
+ if (hasBluetoothPermission()) {
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
bleRequestEnable.launch(enableBtIntent)
+ } else {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(getString(R.string.required_permissions))
+ .setMessage(permissionMissing)
+ .setNeutralButton(R.string.cancel) { _, _ ->
+ warn("User bailed due to permissions")
+ }
+ .setPositiveButton(R.string.accept) { _, _ ->
+ info("requesting permissions")
+ requestPermissionsLauncher.launch(getBluetoothPermissions()) }
+ .show()
}
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt b/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt
index a187ccf9f..3f5837dcf 100644
--- a/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt
+++ b/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt
@@ -8,14 +8,15 @@ import android.companion.CompanionDeviceManager
import android.content.Context
import android.content.pm.PackageManager
import android.hardware.usb.UsbManager
-import android.os.Build
import androidx.core.content.ContextCompat
import com.geeksville.mesh.MainActivity
+import com.geeksville.mesh.R
/**
* @return null on platforms without a BlueTooth driver (i.e. the emulator)
*/
-val Context.bluetoothManager: BluetoothManager? get() = getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager?
+val Context.bluetoothManager: BluetoothManager?
+ get() = getSystemService(Context.BLUETOOTH_SERVICE).takeIf { hasBluetoothPermission() } as? BluetoothManager?
val Context.deviceManager: CompanionDeviceManager?
get() {
@@ -36,7 +37,7 @@ val Context.locationManager: LocationManager get() = requireNotNull(getSystemSer
* @return true if CompanionDeviceManager API is present
*/
fun Context.hasCompanionDeviceApi(): Boolean =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O)
packageManager.hasSystemFeature(PackageManager.FEATURE_COMPANION_DEVICE_SETUP)
else false
@@ -51,6 +52,16 @@ fun Context.hasGps(): Boolean = locationManager.allProviders.contains(LocationMa
fun Context.gpsDisabled(): Boolean =
if (hasGps()) !locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) else false
+/**
+ * return the text string of the permissions missing
+ */
+val Context.permissionMissing: String
+ get() = if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) {
+ getString(R.string.permission_missing)
+ } else {
+ getString(R.string.permission_missing_31)
+ }
+
/**
* return a list of the permissions we don't have
*/
@@ -67,12 +78,12 @@ fun Context.getMissingPermissions(perms: List): Array = perms.fi
fun Context.getBluetoothPermissions(): Array {
val perms = mutableListOf()
-/* TODO - wait for targetSdkVersion 31
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
perms.add(Manifest.permission.BLUETOOTH_SCAN)
perms.add(Manifest.permission.BLUETOOTH_CONNECT)
+ } else if (!hasCompanionDeviceApi()) {
+ perms.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
-*/
return getMissingPermissions(perms)
}
@@ -109,7 +120,7 @@ fun Context.hasLocationPermission() = getLocationPermissions().isEmpty()
fun Context.getBackgroundPermissions(): Array {
val perms = mutableListOf(Manifest.permission.ACCESS_FINE_LOCATION)
- if (Build.VERSION.SDK_INT >= 29) // only added later
+ if (android.os.Build.VERSION.SDK_INT >= 29) // only added later
perms.add(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
return getMissingPermissions(perms)
diff --git a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt
index d027afe91..b10a9af59 100644
--- a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt
@@ -128,10 +128,9 @@ class BTScanModel @Inject constructor(
debug("BTScanModel cleared")
}
- private val bluetoothAdapter = context.bluetoothManager?.adapter
private val deviceManager get() = context.deviceManager
- val hasCompanionDeviceApi get() = context.hasCompanionDeviceApi()
- private val hasBluetoothPermission get() = application.hasBluetoothPermission()
+ val hasCompanionDeviceApi get() = application.hasCompanionDeviceApi()
+ val hasBluetoothPermission get() = application.hasBluetoothPermission()
private val usbManager get() = context.usbManager
var selectedAddress: String? = null
@@ -219,8 +218,8 @@ class BTScanModel @Inject constructor(
fun setupScan(): Boolean {
selectedAddress = radioInterfaceService.getDeviceAddress()
- return if (bluetoothAdapter == null || MockInterface.addressValid(context, usbRepository, "")) {
- warn("No bluetooth adapter. Running under emulation?")
+ return if (MockInterface.addressValid(context, usbRepository, "")) {
+ warn("Running under emulator/test lab")
val testnodes = listOf(
DeviceListEntry("Included simulator", "m", true),
@@ -273,14 +272,16 @@ class BTScanModel @Inject constructor(
private var networkDiscovery: Job? = null
fun startScan() {
+ _spinner.value = true
+
// Start Network Service Discovery (find TCP devices)
networkDiscovery = nsdRepository.networkDiscoveryFlow()
.onEach { addDevice(TCPDeviceListEntry(it)) }
.launchIn(CoroutineScope(Dispatchers.Main))
- if (hasCompanionDeviceApi) {
- startCompanionScan()
- } else startClassicScan()
+ if (hasBluetoothPermission) {
+ if (hasCompanionDeviceApi) startCompanionScan() else startClassicScan()
+ }
}
@SuppressLint("MissingPermission")
@@ -290,7 +291,6 @@ class BTScanModel @Inject constructor(
if (bluetoothLeScanner != null) { // could be null if bluetooth is disabled
debug("starting classic scan")
- _spinner.value = true
// filter and only accept devices that have our service
val filter =
@@ -373,7 +373,6 @@ class BTScanModel @Inject constructor(
@SuppressLint("NewApi")
private fun startCompanionScan() {
debug("starting companion scan")
- _spinner.value = true
deviceManager?.associate(
associationRequest(),
@SuppressLint("NewApi")
@@ -479,7 +478,11 @@ class BTScanModel @Inject constructor(
}
val permissionIntent =
+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) {
PendingIntent.getBroadcast(activity, 0, Intent(ACTION_USB_PERMISSION), 0)
+ } else {
+ PendingIntent.getBroadcast(activity, 0, Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE)
+ }
val filter = IntentFilter(ACTION_USB_PERMISSION)
activity.registerReceiver(usbReceiver, filter)
usbManager.requestPermission(it.usb.device, permissionIntent)
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
index bd0c34edd..0a91fb5c7 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt
@@ -56,7 +56,9 @@ class BluetoothRepository @Inject constructor(
}
fun getRemoteDevice(address: String): BluetoothDevice? {
- return bluetoothAdapterLazy.get()?.takeIf { isValid(address) }?.getRemoteDevice(address)
+ return bluetoothAdapterLazy.get()
+ ?.takeIf { application.hasBluetoothPermission() && isValid(address) }
+ ?.getRemoteDevice(address)
}
fun getBluetoothLeScanner(): BluetoothLeScanner? {
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterface.kt
index 3c843a9c7..6224a587e 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterface.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterface.kt
@@ -1,12 +1,10 @@
package com.geeksville.mesh.repository.radio
-import android.annotation.SuppressLint
-import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattService
-import android.bluetooth.BluetoothManager
import android.content.Context
import com.geeksville.mesh.android.Logging
+import com.geeksville.mesh.android.bluetoothManager
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.repository.usb.UsbRepository
import com.geeksville.mesh.service.*
@@ -110,22 +108,15 @@ class BluetoothInterface(
val BTM_FROMNUM_CHARACTER: UUID =
UUID.fromString("ed9da18c-a800-4f66-a670-aa7547e34453")
- /// Get our bluetooth adapter (should always succeed except on emulator
- private fun getBluetoothAdapter(context: Context): BluetoothAdapter? {
- val bluetoothManager =
- context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
- return bluetoothManager.adapter
- }
-
/** Return true if this address is still acceptable. For BLE that means, still bonded */
- @SuppressLint("NewApi", "MissingPermission")
override fun addressValid(
context: Context,
usbRepository: UsbRepository, // Temporary until dependency injection transition is completed
rest: String
): Boolean {
- val allPaired = getBluetoothAdapter(context)?.bondedDevices.orEmpty()
- .map { it.address }.toSet()
+ /// Get our bluetooth adapter (should always succeed except on emulator
+ val allPaired = context.bluetoothManager?.adapter?.bondedDevices.orEmpty()
+ .map { it.address }.toSet()
return if (!allPaired.contains(rest)) {
warn("Ignoring stale bond to ${rest.anonymize}")
false
@@ -170,7 +161,7 @@ class BluetoothInterface(
init {
// Note: this call does no comms, it just creates the device object (even if the
// device is off/not connected)
- val device = getBluetoothAdapter(context)?.getRemoteDevice(address)
+ val device = context.bluetoothManager?.adapter?.getRemoteDevice(address)
if (device != null) {
info("Creating radio interface service. device=${address.anonymize}")
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt
index d56ad88be..d2f5f4da1 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt
@@ -106,7 +106,11 @@ class MeshServiceNotifications(
)
private val openAppIntent: PendingIntent by lazy {
- PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), 0)
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), 0)
+ } else {
+ PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)
+ }
}
/**
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 4f37591da..d03cc0a93 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt
@@ -261,7 +261,10 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (permissions.entries.all { it.value }) {
binding.provideLocationCheckbox.isChecked = true
- } else debug("User denied background permission")
+ } else {
+ debug("User denied background permission")
+ showSnackbar(getString(R.string.why_background_required))
+ }
}
val requestLocationAndBackgroundLauncher =
@@ -271,7 +274,10 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
if (myActivity.hasBackgroundPermission()) {
binding.provideLocationCheckbox.isChecked = true
} else requestBackgroundAndCheckLauncher.launch(myActivity.getBackgroundPermissions())
- } else debug("User denied location permission")
+ } else {
+ debug("User denied location permission")
+ showSnackbar(getString(R.string.why_background_required))
+ }
}
// init our region spinner
@@ -506,37 +512,34 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
val requestPermissionAndScanLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (permissions.entries.all { it.value }) {
- checkLocationEnabled()
+ checkBTEnabled()
+ if (!scanModel.hasCompanionDeviceApi) checkLocationEnabled()
scanLeDevice()
} else {
errormsg("User denied scan permissions")
- showSnackbar(getString(R.string.permission_missing))
+ showSnackbar(requireContext().permissionMissing)
}
+ bluetoothViewModel.permissionsUpdated()
}
binding.changeRadioButton.setOnClickListener {
debug("User clicked changeRadioButton")
- checkBTEnabled()
- if ((scanModel.hasCompanionDeviceApi)) {
- scanLeDevice()
- } else {
- // Location is the only runtime permission for classic bluetooth scan
- if (myActivity.hasLocationPermission()) {
- checkLocationEnabled()
- scanLeDevice()
- } else {
- MaterialAlertDialogBuilder(requireContext())
- .setTitle(getString(R.string.required_permissions))
- .setMessage(getString(R.string.permission_missing))
- .setNeutralButton(R.string.cancel) { _, _ ->
- warn("User bailed due to permissions")
- }
- .setPositiveButton(R.string.accept) { _, _ ->
- info("requesting scan permissions")
- requestPermissionAndScanLauncher.launch(myActivity.getLocationPermissions())
- }
- .show()
- }
+ scanLeDevice()
+ if (scanModel.hasBluetoothPermission) {
+ checkBTEnabled()
+ if (!scanModel.hasCompanionDeviceApi) checkLocationEnabled()
+ } else {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(getString(R.string.required_permissions))
+ .setMessage(requireContext().permissionMissing)
+ .setNeutralButton(R.string.cancel) { _, _ ->
+ warn("User bailed due to permissions")
+ }
+ .setPositiveButton(R.string.accept) { _, _ ->
+ info("requesting scan permissions")
+ requestPermissionAndScanLauncher.launch(myActivity.getBluetoothPermissions())
+ }
+ .show()
}
}
}