diff --git a/app/build.gradle b/app/build.gradle
index 326c57e00..9c601629f 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -21,6 +21,8 @@ if (keystorePropertiesFile.exists()) {
}
android {
+ namespace 'com.geeksville.mesh'
+
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
@@ -29,11 +31,11 @@ android {
storePassword keystoreProperties['storePassword']
}
}
- compileSdkVersion 33
+ compileSdk 33
defaultConfig {
applicationId "com.geeksville.mesh"
minSdkVersion 21 // The oldest emulator image I have tried is 22 (though 21 probably works)
- targetSdkVersion 31
+ targetSdk 33
versionCode 30202 // format is Mmmss (where M is 1+the numeric major number
versionName "2.2.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -41,7 +43,7 @@ android {
// per https://developer.android.com/studio/write/vector-asset-studio
vectorDrawables.useSupportLibrary = true
}
- flavorDimensions 'default'
+ flavorDimensions = ['default']
productFlavors {
fdroid {
dimension = 'default'
@@ -98,7 +100,6 @@ android {
lint {
abortOnError false
}
- namespace 'com.geeksville.mesh'
}
// per protobuf-gradle-plugin docs, this is recommended for android
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c8a4a743c..dfd387cf9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -23,6 +23,9 @@
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
+
+
+
@@ -57,6 +60,10 @@
+
+
diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
index 54c51bc65..9f6d9bcc3 100644
--- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt
+++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
@@ -117,16 +117,28 @@ class MainActivity : AppCompatActivity(), Logging {
private val bluetoothViewModel: BluetoothViewModel by viewModels()
private val model: UIViewModel by viewModels()
- private val requestPermissionsLauncher =
- registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
- if (!permissions.entries.all { it.value }) {
- errormsg("User denied permissions")
+ private val bluetoothPermissionsLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
+ if (result.entries.all { it.value }) {
+ info("Bluetooth permissions granted")
+ } else {
+ warn("Bluetooth permissions denied")
showSnackbar(permissionMissing)
}
requestedEnable = false
bluetoothViewModel.permissionsUpdated()
}
+ private val notificationPermissionsLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
+ if (result.entries.all { it.value }) {
+ info("Notification permissions granted")
+ } else {
+ warn("Notification permissions denied")
+ showSnackbar(getString(R.string.notification_denied))
+ }
+ }
+
data class TabInfo(val text: String, val icon: Int, val content: Fragment)
private val tabInfos = arrayOf(
@@ -385,6 +397,17 @@ class MainActivity : AppCompatActivity(), Logging {
if (model.provideLocation.value == true)
service.startProvideLocation()
}
+
+ if (!hasNotificationPermission()) {
+ val notificationPermissions = getNotificationPermissions()
+ rationaleDialog(
+ shouldShowRequestPermissionRationale(notificationPermissions),
+ R.string.notification_required,
+ getString(R.string.why_notification_required),
+ ) {
+ notificationPermissionsLauncher.launch(notificationPermissions)
+ }
+ }
} else {
// For other connection states, just slam them in
model.setConnectionState(newConnection)
@@ -640,17 +663,10 @@ class MainActivity : AppCompatActivity(), Logging {
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()
+ val bluetoothPermissions = getBluetoothPermissions()
+ rationaleDialog(shouldShowRequestPermissionRationale(bluetoothPermissions)) {
+ bluetoothPermissionsLauncher.launch(bluetoothPermissions)
+ }
}
}
}
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 f9aafae86..1c47c88c5 100644
--- a/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt
+++ b/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt
@@ -2,15 +2,19 @@ package com.geeksville.mesh.android
import android.Manifest
import android.annotation.SuppressLint
+import android.app.Activity
import android.app.NotificationManager
import android.bluetooth.BluetoothManager
-import android.location.LocationManager
import android.companion.CompanionDeviceManager
import android.content.Context
import android.content.pm.PackageManager
import android.hardware.usb.UsbManager
+import android.location.LocationManager
+import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
import com.geeksville.mesh.R
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
/**
* @return null on platforms without a BlueTooth driver (i.e. the emulator)
@@ -48,7 +52,7 @@ fun Context.gpsDisabled(): Boolean =
if (hasGps()) !locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) else false
/**
- * return the text string of the permissions missing
+ * @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) {
@@ -57,6 +61,55 @@ val Context.permissionMissing: String
getString(R.string.permission_missing_31)
}
+/**
+ * Checks if any given permissions need to show rationale.
+ *
+ * @return true if should show UI with rationale before requesting a permission.
+ */
+fun Activity.shouldShowRequestPermissionRationale(permissions: Array): Boolean {
+ for (permission in permissions) {
+ if (ActivityCompat.shouldShowRequestPermissionRationale(this, permission)) {
+ return true
+ }
+ }
+ return false
+}
+
+/**
+ * Checks if any given permissions need to show rationale.
+ *
+ * @return true if should show UI with rationale before requesting a permission.
+ */
+fun Fragment.shouldShowRequestPermissionRationale(permissions: Array): Boolean {
+ for (permission in permissions) {
+ if (shouldShowRequestPermissionRationale(permission)) {
+ return true
+ }
+ }
+ return false
+}
+
+/**
+ * Handles whether a rationale dialog should be shown before performing an action.
+ */
+fun Context.rationaleDialog(
+ shouldShowRequestPermissionRationale: Boolean = true,
+ title: Int = R.string.required_permissions,
+ rationale: CharSequence = permissionMissing,
+ invokeFun: () -> Unit,
+) {
+ if (!shouldShowRequestPermissionRationale) invokeFun()
+ else MaterialAlertDialogBuilder(this)
+ .setTitle(title)
+ .setMessage(rationale)
+ .setNeutralButton(R.string.cancel) { _, _ ->
+ }
+ .setPositiveButton(R.string.accept) { _, _ ->
+ invokeFun()
+ }
+ .show()
+}
+
/**
* return a list of the permissions we don't have
*/
@@ -123,3 +176,17 @@ fun Context.getBackgroundPermissions(): Array {
/** @return true if the user already has background location permission */
fun Context.hasBackgroundPermission() = getBackgroundPermissions().isEmpty()
+
+/**
+ * Notification permission (or empty if we already have what we need)
+ */
+fun Context.getNotificationPermissions(): Array {
+ val perms = mutableListOf()
+ if (android.os.Build.VERSION.SDK_INT >= 33)
+ perms.add(Manifest.permission.POST_NOTIFICATIONS)
+
+ return getMissingPermissions(perms)
+}
+
+/** @return true if the user already has notification permission */
+fun Context.hasNotificationPermission() = getNotificationPermissions().isEmpty()
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 c927e4fcd..392e4c22d 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt
@@ -662,11 +662,12 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
val requestPermissionAndScanLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (permissions.entries.all { it.value }) {
+ info("Bluetooth permissions granted")
checkBTEnabled()
if (!hasCompanionDeviceApi) checkLocationEnabled()
scanLeDevice()
} else {
- errormsg("User denied scan permissions")
+ warn("Bluetooth permissions denied")
model.showSnackbar(requireContext().permissionMissing)
}
bluetoothViewModel.permissionsUpdated()
@@ -675,21 +676,16 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
binding.changeRadioButton.setOnClickListener {
debug("User clicked changeRadioButton")
scanLeDevice()
- if (requireContext().hasBluetoothPermission()) {
+ val bluetoothPermissions = requireContext().getBluetoothPermissions()
+ if (bluetoothPermissions.isEmpty()) {
checkBTEnabled()
if (!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(requireContext().getBluetoothPermissions())
- }
- .show()
+ requireContext().rationaleDialog(
+ shouldShowRequestPermissionRationale(bluetoothPermissions)
+ ) {
+ requestPermissionAndScanLauncher.launch(bluetoothPermissions)
+ }
}
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c99c9849d..5ebace2ae 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -106,6 +106,9 @@
Provide phone location to mesh
Camera permission
We must be granted access to the camera to read QR codes. No pictures or videos will be saved.
+ Notification permission
+ Meshtastic needs permission for service and message notifications.
+ Notification permission denied. To turn on notifications, access: Android Settings > Apps > Meshtastic > Notifications.
Short Range / Slow
Medium Range / Slow
diff --git a/gradle.properties b/gradle.properties
index f5ea6f07b..51c624449 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -4,20 +4,27 @@
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
+
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Ensure important default jvmargs aren't overwritten. See https://github.com/gradle/gradle/issues/19750
org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g
+
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
+
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
+
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
+
+# Disable build features that are enabled by default,
+# https://developer.android.com/build/releases/gradle-plugin#default-changes
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false