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