From 2c6751a574d61d42f956c4807bc76450f53593c7 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 29 Jul 2025 09:42:36 -0500 Subject: [PATCH] feat: onboarding refresh (#2551) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/build.gradle.kts | 1 + .../java/com/geeksville/mesh/MainActivity.kt | 309 +-- .../mesh/android/ContextServices.kt | 173 +- .../com/geeksville/mesh/model/BTScanModel.kt | 10 +- .../mesh/model/BluetoothViewModel.kt | 18 +- .../bluetooth/BluetoothRepository.kt | 98 +- .../geeksville/mesh/service/MeshService.kt | 1783 ++++++++--------- .../mesh/service/MeshServiceNotifications.kt | 5 +- .../geeksville/mesh/service/SafeBluetooth.kt | 836 ++++---- .../main/java/com/geeksville/mesh/ui/Main.kt | 19 +- .../mesh/ui/connections/Connections.kt | 153 +- .../ui/connections/components/BLEDevices.kt | 155 +- .../mesh/ui/intro/AppIntroComponents.kt | 214 -- .../mesh/ui/intro/AppIntroductionScreen.kt | 111 + .../mesh/ui/intro/CriticalAlertsScreen.kt | 76 + .../geeksville/mesh/ui/intro/FeatureUIData.kt | 34 + .../mesh/ui/intro/IntroBottomBar.kt | 63 + .../geeksville/mesh/ui/intro/IntroRoute.kt | 29 + .../mesh/ui/intro/IntroUiHelpers.kt | 104 + .../mesh/ui/intro/LocationScreen.kt | 88 + .../mesh/ui/intro/NotificationsScreen.kt | 106 + .../mesh/ui/intro/PermissionScreenLayout.kt | 128 ++ .../geeksville/mesh/ui/intro/WelcomeScreen.kt | 104 + .../com/geeksville/mesh/ui/map/MapView.kt | 67 +- .../com/geeksville/mesh/ui/sharing/Channel.kt | 322 ++- .../mesh/ui/sharing/ContactSharing.kt | 257 +-- app/src/main/res/values/strings.xml | 43 +- gradle/libs.versions.toml | 2 + 28 files changed, 2795 insertions(+), 2513 deletions(-) delete mode 100644 app/src/main/java/com/geeksville/mesh/ui/intro/AppIntroComponents.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/intro/AppIntroductionScreen.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/intro/CriticalAlertsScreen.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/intro/FeatureUIData.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/intro/IntroBottomBar.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/intro/IntroRoute.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/intro/IntroUiHelpers.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/intro/LocationScreen.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/intro/NotificationsScreen.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/intro/PermissionScreenLayout.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/intro/WelcomeScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bdcf565eb..bba0c8370 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -224,6 +224,7 @@ dependencies { implementation(libs.usb.serial.android) implementation(libs.work.runtime.ktx) implementation(libs.core.location.altitude) + implementation(libs.accompanist.permissions) // Compose BOM implementation(platform(libs.compose.bom)) diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index dd86e5a22..3ebf362c2 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -19,16 +19,12 @@ package com.geeksville.mesh import android.app.PendingIntent import android.app.TaskStackBuilder -import android.bluetooth.BluetoothAdapter import android.content.Intent import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.hardware.usb.UsbManager import android.net.Uri import android.os.Bundle -import android.os.RemoteException -import android.provider.Settings -import android.view.MotionEvent import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -45,19 +41,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalView import androidx.core.content.edit import androidx.core.net.toUri -import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope 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.getBluetoothPermissions -import com.geeksville.mesh.android.getNotificationPermissions -import com.geeksville.mesh.android.hasBluetoothPermission -import com.geeksville.mesh.android.hasNotificationPermission -import com.geeksville.mesh.android.permissionMissing -import com.geeksville.mesh.android.shouldShowRequestPermissionRationale import com.geeksville.mesh.concurrent.handledLaunch import com.geeksville.mesh.model.BluetoothViewModel import com.geeksville.mesh.model.UIViewModel @@ -69,9 +58,8 @@ import com.geeksville.mesh.ui.MainMenuAction import com.geeksville.mesh.ui.MainScreen import com.geeksville.mesh.ui.common.theme.AppTheme import com.geeksville.mesh.ui.common.theme.MODE_DYNAMIC -import com.geeksville.mesh.ui.sharing.toSharedContact import com.geeksville.mesh.ui.intro.AppIntroductionScreen -import com.geeksville.mesh.util.Exceptions +import com.geeksville.mesh.ui.sharing.toSharedContact import com.geeksville.mesh.util.LanguageUtils import com.geeksville.mesh.util.getPackageInfoCompat import dagger.hilt.android.AndroidEntryPoint @@ -79,43 +67,19 @@ import kotlinx.coroutines.Job import javax.inject.Inject @AndroidEntryPoint -class MainActivity : AppCompatActivity(), Logging { +class MainActivity : + AppCompatActivity(), + Logging { private val bluetoothViewModel: BluetoothViewModel by viewModels() private val model: UIViewModel by viewModels() - @Inject - internal lateinit var serviceRepository: ServiceRepository + @Inject internal lateinit var serviceRepository: ServiceRepository private var showAppIntro by mutableStateOf(false) - private val bluetoothPermissionsLauncher = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> - if (result.entries.all { it.value }) { - info("Bluetooth permissions granted") - } else { - warn("Bluetooth permissions denied") - model.showSnackbar(permissionMissing) - } - requestedEnable = false - bluetoothViewModel.permissionsUpdated() - } - - private val notificationPermissionsLauncher = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> - if (result.entries.all { it.value }) { - info("Notification permissions granted") - checkAlertDnD() - } else { - warn("Notification permissions denied") - model.showSnackbar(getString(R.string.notification_denied)) - } - } - override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() - installSplashScreen() super.onCreate(savedInstanceState) - val prefs = UIViewModel.getPreferences(this) if (savedInstanceState == null) { val lang = prefs.getString("lang", LanguageUtils.SYSTEM_DEFAULT) @@ -133,30 +97,28 @@ class MainActivity : AppCompatActivity(), Logging { setContent { val theme by model.theme.collectAsState() val dynamic = theme == MODE_DYNAMIC - val dark = when (theme) { - AppCompatDelegate.MODE_NIGHT_YES -> true - AppCompatDelegate.MODE_NIGHT_NO -> false - AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme() - else -> isSystemInDarkTheme() - } + val dark = + when (theme) { + AppCompatDelegate.MODE_NIGHT_YES -> true + AppCompatDelegate.MODE_NIGHT_NO -> false + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme() + else -> isSystemInDarkTheme() + } - AppTheme( - dynamicColor = dynamic, - darkTheme = dark, - ) { + AppTheme(dynamicColor = dynamic, darkTheme = dark) { val view = LocalView.current if (!view.isInEditMode) { - SideEffect { - AppCompatDelegate.setDefaultNightMode(theme) - } + SideEffect { AppCompatDelegate.setDefaultNightMode(theme) } } if (showAppIntro) { - AppIntroductionScreen(onDone = { - prefs.edit { putBoolean("app_intro_completed", true) } - showAppIntro = false - (application as GeeksvilleApplication).askToRate(this@MainActivity) - }) + AppIntroductionScreen( + onDone = { + prefs.edit { putBoolean("app_intro_completed", true) } + showAppIntro = false + (application as GeeksvilleApplication).askToRate(this@MainActivity) + }, + ) } else { MainScreen( uIViewModel = model, @@ -182,14 +144,10 @@ class MainActivity : AppCompatActivity(), Logging { Intent.ACTION_VIEW -> { appLinkData?.let { debug("App link data: $it") - if (it.path?.startsWith("/e/") == true || - it.path?.startsWith("/E/") == true - ) { + if (it.path?.startsWith("/e/") == true || it.path?.startsWith("/E/") == true) { debug("App link data is a channel set") model.requestChannelUrl(it) - } else if (it.path?.startsWith("/v/") == true || - it.path?.startsWith("/V/") == true - ) { + } else if (it.path?.startsWith("/v/") == true || it.path?.startsWith("/V/") == true) { val sharedContact = it.toSharedContact() debug("App link data is a shared contact: ${sharedContact.user.longName}") model.setSharedContactRequested(sharedContact) @@ -204,8 +162,7 @@ class MainActivity : AppCompatActivity(), Logging { showSettingsPage() } - Intent.ACTION_MAIN -> { - } + Intent.ACTION_MAIN -> {} Intent.ACTION_SEND -> { val text = intent.getStringExtra(Intent.EXTRA_TEXT) @@ -222,138 +179,60 @@ class MainActivity : AppCompatActivity(), Logging { private fun createShareIntent(message: String): PendingIntent { val deepLink = "$DEEP_LINK_BASE_URI/share?message=$message" - val startActivityIntent = Intent( - Intent.ACTION_VIEW, deepLink.toUri(), - this, MainActivity::class.java - ).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP - } + val startActivityIntent = + Intent(Intent.ACTION_VIEW, deepLink.toUri(), this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } - val resultPendingIntent: PendingIntent? = TaskStackBuilder.create(this).run { - addNextIntentWithParentStack(startActivityIntent) - getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE) - } + val resultPendingIntent: PendingIntent? = + TaskStackBuilder.create(this).run { + addNextIntentWithParentStack(startActivityIntent) + getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE) + } return resultPendingIntent!! } private fun createSettingsIntent(): PendingIntent { val deepLink = "$DEEP_LINK_BASE_URI/connections" - val startActivityIntent = Intent( - Intent.ACTION_VIEW, deepLink.toUri(), - this, MainActivity::class.java - ).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP - } + val startActivityIntent = + Intent(Intent.ACTION_VIEW, deepLink.toUri(), this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } - val resultPendingIntent: PendingIntent? = TaskStackBuilder.create(this).run { - addNextIntentWithParentStack(startActivityIntent) - getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE) - } + val resultPendingIntent: PendingIntent? = + TaskStackBuilder.create(this).run { + addNextIntentWithParentStack(startActivityIntent) + getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE) + } return resultPendingIntent!! } - private var requestedEnable = false - private val bleRequestEnable = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { - requestedEnable = false - } - - private val createDocumentLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { - if (it.resultCode == RESULT_OK) { - it.data?.data?.let { file_uri -> model.saveMessagesCSV(file_uri) } - } - } - - private fun onMeshConnectionChanged(newConnection: MeshService.ConnectionState) { - if (newConnection == MeshService.ConnectionState.CONNECTED) { - checkNotificationPermissions() - } - } - - private fun checkNotificationPermissions() { - if (!hasNotificationPermission()) { - val notificationPermissions = getNotificationPermissions() - if (shouldShowRequestPermissionRationale(notificationPermissions)) { - val title = getString(R.string.notification_required) - val message = getString(R.string.why_notification_required) - model.showAlert( - title = title, - message = message, - onConfirm = { - notificationPermissionsLauncher.launch(notificationPermissions) - }, - ) - } else { - notificationPermissionsLauncher.launch(notificationPermissions) + private val createDocumentLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) { + it.data?.data?.let { file_uri -> model.saveMessagesCSV(file_uri) } } } - } - - @Suppress("MagicNumber") - private fun checkAlertDnD() { - val prefs = UIViewModel.getPreferences(this) - val rationaleShown = prefs.getBoolean("dnd_rationale_shown", false) - if (!rationaleShown && hasNotificationPermission()) { - fun showAlertAppNotificationSettings() { - val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) - intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName) - intent.putExtra(Settings.EXTRA_CHANNEL_ID, "my_alerts") - startActivity(intent) - } - model.showAlert( - title = getString(R.string.alerts_dnd_request_title), - html = getString(R.string.alerts_dnd_request_text), - onConfirm = { - showAlertAppNotificationSettings() - }, - ).also { - prefs.edit { putBoolean("dnd_rationale_shown", true) } - } - } - } - - override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { - return try { - super.dispatchTouchEvent(ev) - } catch (ex: Throwable) { - Exceptions.report( - ex, - "dispatchTouchEvent" - ) // hide this Compose error from the user but report to the mothership - false - } - } private var serviceSetupJob: Job? = null - private val mesh = object : ServiceClient(IMeshService.Stub::asInterface) { - override fun onConnected(service: IMeshService) { - serviceSetupJob?.cancel() - serviceSetupJob = lifecycleScope.handledLaunch { - serviceRepository.setMeshService(service) + private val mesh = + object : ServiceClient(IMeshService.Stub::asInterface) { + override fun onConnected(service: IMeshService) { + serviceSetupJob?.cancel() + serviceSetupJob = + lifecycleScope.handledLaunch { + serviceRepository.setMeshService(service) + debug("connected to mesh service, connectionState=${model.connectionState.value}") + } + } - try { - val connectionState = - MeshService.ConnectionState.valueOf(service.connectionState()) - - onMeshConnectionChanged(connectionState) - } catch (ex: RemoteException) { - errormsg("Device error during init ${ex.message}") - } - - debug("connected to mesh service, connectionState=${model.connectionState.value}") + override fun onDisconnected() { + serviceSetupJob?.cancel() + serviceRepository.setMeshService(null) } } - override fun onDisconnected() { - serviceSetupJob?.cancel() - serviceRepository.setMeshService(null) - } - } - private fun bindMeshService() { debug("Binding to mesh service!") try { @@ -362,35 +241,11 @@ class MainActivity : AppCompatActivity(), Logging { errormsg("Failed to start service from activity - but ignoring because bind will work ${ex.message}") } - mesh.connect( - this, - MeshService.createIntent(), - BIND_AUTO_CREATE + BIND_ABOVE_CLIENT - ) + mesh.connect(this, MeshService.createIntent(), BIND_AUTO_CREATE + BIND_ABOVE_CLIENT) } override fun onStart() { super.onStart() - bluetoothViewModel.enabled.observe(this) { enabled -> - if (!enabled && !requestedEnable && model.selectedBluetooth) { - requestedEnable = true - if (hasBluetoothPermission()) { - val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) - bleRequestEnable.launch(enableBtIntent) - } else { - val bluetoothPermissions = getBluetoothPermissions() - val title = getString(R.string.required_permissions) - val message = permissionMissing - model.showAlert( - title = title, - message = message, - onConfirm = { - bluetoothPermissionsLauncher.launch(bluetoothPermissions) - }, - ) - } - } - } try { bindMeshService() @@ -410,11 +265,12 @@ class MainActivity : AppCompatActivity(), Logging { } MainMenuAction.EXPORT_MESSAGES -> { - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "application/csv" - putExtra(Intent.EXTRA_TITLE, "rangetest.csv") - } + val intent = + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/csv" + putExtra(Intent.EXTRA_TITLE, "rangetest.csv") + } createDocumentLauncher.launch(intent) } @@ -427,7 +283,7 @@ class MainActivity : AppCompatActivity(), Logging { } MainMenuAction.SHOW_INTRO -> { - showAppIntro = true // Show intro again if selected from menu + showAppIntro = true } else -> {} @@ -445,12 +301,13 @@ class MainActivity : AppCompatActivity(), Logging { } private fun chooseThemeDialog() { - val styles = mapOf( - getString(R.string.dynamic) to MODE_DYNAMIC, - getString(R.string.theme_light) to AppCompatDelegate.MODE_NIGHT_NO, - getString(R.string.theme_dark) to AppCompatDelegate.MODE_NIGHT_YES, - getString(R.string.theme_system) to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - ) + val styles = + mapOf( + getString(R.string.dynamic) to MODE_DYNAMIC, + getString(R.string.theme_light) to AppCompatDelegate.MODE_NIGHT_NO, + getString(R.string.theme_dark) to AppCompatDelegate.MODE_NIGHT_YES, + getString(R.string.theme_system) to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, + ) val prefs = UIViewModel.getPreferences(this) val theme = prefs.getInt("theme", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) @@ -458,11 +315,7 @@ class MainActivity : AppCompatActivity(), Logging { model.showAlert( title = getString(R.string.choose_theme), message = "", - choices = styles.mapValues { (_, value) -> - { - model.setTheme(value) - } - }, + choices = styles.mapValues { (_, value) -> { model.setTheme(value) } }, ) } @@ -470,16 +323,8 @@ class MainActivity : AppCompatActivity(), Logging { val languageTags = LanguageUtils.getLanguageTags(this) val lang = LanguageUtils.getLocale() debug("Lang from prefs: $lang") - val langMap = languageTags.mapValues { (_, value) -> - { - LanguageUtils.setLocale(value) - } - } + val langMap = languageTags.mapValues { (_, value) -> { LanguageUtils.setLocale(value) } } - model.showAlert( - title = getString(R.string.preferences_language), - message = "", - choices = langMap, - ) + model.showAlert(title = getString(R.string.preferences_language), message = "", choices = langMap) } } 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 e09594770..818c23a8e 100644 --- a/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt +++ b/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt @@ -18,161 +18,58 @@ package com.geeksville.mesh.android import android.Manifest -import android.app.Activity -import android.app.NotificationManager -import android.bluetooth.BluetoothManager import android.content.Context import android.content.pm.PackageManager import android.location.LocationManager -import androidx.core.app.ActivityCompat +import android.os.Build 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) - */ -val Context.bluetoothManager: BluetoothManager? - get() = getSystemService(Context.BLUETOOTH_SERVICE).takeIf { hasBluetoothPermission() } as? BluetoothManager? +/** Checks if the device has a GPS receiver. */ +fun Context.hasGps(): Boolean { + val lm = getSystemService(Context.LOCATION_SERVICE) as? LocationManager + return lm?.allProviders?.contains(LocationManager.GPS_PROVIDER) == true +} -val Context.notificationManager: NotificationManager get() = requireNotNull(getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager?) - -val Context.locationManager: LocationManager get() = requireNotNull(getSystemService(Context.LOCATION_SERVICE) as? LocationManager?) - -/** - * @return true if the device has a GPS receiver - */ -fun Context.hasGps(): Boolean = locationManager.allProviders.contains(LocationManager.GPS_PROVIDER) - -/** - * @return true if the device has a GPS receiver and it is disabled (location turned off) - */ -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) +/** Checks if the device has a GPS receiver and it is currently disabled. */ +fun Context.gpsDisabled(): Boolean { + val lm = getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return false + return if (lm.allProviders.contains(LocationManager.GPS_PROVIDER)) { + !lm.isProviderEnabled(LocationManager.GPS_PROVIDER) } else { - getString(R.string.permission_missing_31) + false } +} /** - * Checks if any given permissions need to show rationale. + * Determines the list of Bluetooth permissions that are currently missing. Internal helper for + * [hasBluetoothPermission]. * - * @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. + * For Android S (API 31) and above, this includes [Manifest.permission.BLUETOOTH_SCAN] and + * [Manifest.permission.BLUETOOTH_CONNECT]. For older versions, it includes [Manifest.permission.ACCESS_FINE_LOCATION] + * as it is required for Bluetooth scanning. * - * @return true if should show UI with rationale before requesting a permission. + * @return Array of missing Bluetooth permission strings. Empty if all are granted. */ -fun Fragment.shouldShowRequestPermissionRationale(permissions: Array): Boolean { - for (permission in permissions) { - if (shouldShowRequestPermissionRationale(permission)) { - return true - } - } - return false -} +private fun Context.getBluetoothPermissions(): Array { + val requiredPermissions = mutableListOf() -/** - * 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 - */ -fun Context.getMissingPermissions(perms: List): Array = perms.filter { - ContextCompat.checkSelfPermission( - this, - it - ) != PackageManager.PERMISSION_GRANTED -}.toTypedArray() - -/** - * Bluetooth permissions (or empty if we already have what we need) - */ -fun Context.getBluetoothPermissions(): Array { - val perms = mutableListOf() - - 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) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + requiredPermissions.add(Manifest.permission.BLUETOOTH_SCAN) + requiredPermissions.add(Manifest.permission.BLUETOOTH_CONNECT) } else { - perms.add(Manifest.permission.ACCESS_FINE_LOCATION) + // ACCESS_FINE_LOCATION is required for Bluetooth scanning on pre-S devices. + requiredPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION) } - return getMissingPermissions(perms) + return requiredPermissions + .filter { ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED } + .toTypedArray() } -/** @return true if the user already has Bluetooth connect permission */ -fun Context.hasBluetoothPermission() = getBluetoothPermissions().isEmpty() +/** Checks if all necessary Bluetooth permissions have been granted. */ +fun Context.hasBluetoothPermission(): Boolean = getBluetoothPermissions().isEmpty() -/** - * Camera permission (or empty if we already have what we need) - */ -fun Context.getCameraPermissions(): Array { - val perms = mutableListOf(Manifest.permission.CAMERA) - - return getMissingPermissions(perms) +/** @return true if the user already has location permission (ACCESS_FINE_LOCATION). */ +fun Context.hasLocationPermission(): Boolean { + val perms = listOf(Manifest.permission.ACCESS_FINE_LOCATION) + return perms.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED } } - -/** @return true if the user already has camera permission */ -fun Context.hasCameraPermission() = getCameraPermissions().isEmpty() - -/** - * Location permission (or empty if we already have what we need) - */ -fun Context.getLocationPermissions(): Array { - val perms = mutableListOf(Manifest.permission.ACCESS_FINE_LOCATION) - - return getMissingPermissions(perms) -} - -/** @return true if the user already has location permission */ -fun Context.hasLocationPermission() = getLocationPermissions().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/model/BTScanModel.kt b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt index 39dc3a5d5..6b252f89d 100644 --- a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt @@ -255,6 +255,11 @@ constructor( _spinner.value = false } + fun refreshPermissions() { + // Refresh the Bluetooth state to ensure we have the latest permissions + bluetoothRepository.refreshState() + } + @SuppressLint("MissingPermission") fun startScan() { debug("starting classic scan") @@ -292,7 +297,8 @@ constructor( } @SuppressLint("MissingPermission") - private fun requestBonding(device: BluetoothDevice) { + private fun requestBonding(it: DeviceListEntry) { + val device = bluetoothRepository.getRemoteDevice(it.address) ?: return info("Starting bonding for ${device.anonymize}") bluetoothRepository @@ -350,7 +356,7 @@ constructor( changeDeviceAddress(it.fullAddress) true } else { - requestBonding(it.device) + requestBonding(it) false } } diff --git a/app/src/main/java/com/geeksville/mesh/model/BluetoothViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/BluetoothViewModel.kt index ba60e9727..fc28f8e5c 100644 --- a/app/src/main/java/com/geeksville/mesh/model/BluetoothViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/BluetoothViewModel.kt @@ -18,24 +18,16 @@ 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`. - */ +/** 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. - */ +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 + val enabled = bluetoothRepository.state.map { it.enabled } +} 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 cab762220..817865e2f 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 @@ -17,7 +17,6 @@ package com.geeksville.mesh.repository.bluetooth -import android.annotation.SuppressLint import android.app.Application import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice @@ -28,8 +27,8 @@ 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.Logging import com.geeksville.mesh.android.hasBluetoothPermission import com.geeksville.mesh.util.registerReceiverCompat import kotlinx.coroutines.flow.Flow @@ -42,22 +41,25 @@ import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton -/** - * Repository responsible for maintaining and updating the state of Bluetooth availability. - */ +/** Repository responsible for maintaining and updating the state of Bluetooth availability. */ @Singleton -class BluetoothRepository @Inject constructor( +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 - )) + 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 { @@ -70,62 +72,58 @@ class BluetoothRepository @Inject constructor( } fun refreshState() { - processLifecycle.coroutineScope.launch(dispatchers.default) { - updateBluetoothState() - } + processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() } } /** @return true for a valid Bluetooth address, false otherwise */ - fun isValid(bleAddress: String): Boolean { - return BluetoothAdapter.checkBluetoothAddress(bleAddress) - } + fun isValid(bleAddress: String): Boolean = BluetoothAdapter.checkBluetoothAddress(bleAddress) - fun getRemoteDevice(address: String): BluetoothDevice? { - return bluetoothAdapterLazy.get() - ?.takeIf { application.hasBluetoothPermission() && isValid(address) } - ?.getRemoteDevice(address) - } + fun getRemoteDevice(address: String): BluetoothDevice? = bluetoothAdapterLazy + .get() + ?.takeIf { application.hasBluetoothPermission() && isValid(address) } + ?.getRemoteDevice(address) - private fun getBluetoothLeScanner(): BluetoothLeScanner? { - return bluetoothAdapterLazy.get() - ?.takeIf { application.hasBluetoothPermission() } - ?.bluetoothLeScanner - } + private fun getBluetoothLeScanner(): BluetoothLeScanner? = + bluetoothAdapterLazy.get()?.takeIf { application.hasBluetoothPermission() }?.bluetoothLeScanner - @SuppressLint("MissingPermission") fun scan(): Flow { - 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 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() + 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() + 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 = device.createBond(application) - @SuppressLint("MissingPermission") internal suspend fun updateBluetoothState() { val hasPerms = application.hasBluetoothPermission() - val newState: BluetoothState = bluetoothAdapterLazy.get()?.let { adapter -> - val enabled = adapter.isEnabled - val bondedDevices = adapter.takeIf { hasPerms }?.bondedDevices ?: emptySet() + val newState: BluetoothState = + bluetoothAdapterLazy.get()?.let { adapter -> + val enabled = adapter.isEnabled + val bondedDevices = adapter.takeIf { hasPerms }?.bondedDevices ?: emptySet() - BluetoothState( - hasPermissions = hasPerms, - enabled = enabled, - bondedDevices = if (!enabled) emptyList() - else bondedDevices.filter { it.name?.matches(Regex(BLE_NAME_PATTERN)) == true }, - ) - } ?: BluetoothState() + BluetoothState( + hasPermissions = hasPerms, + enabled = enabled, + bondedDevices = + if (!enabled) { + emptyList() + } else { + bondedDevices.filter { it.name?.matches(Regex(BLE_NAME_PATTERN)) == true } + }, + ) + } ?: BluetoothState() _state.emit(newState) debug("Detected our bluetooth access=$newState") 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 02ef2b014..3a01c1424 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -23,6 +23,7 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.pm.ServiceInfo +import android.os.Build import android.os.IBinder import android.os.RemoteException import androidx.core.app.ServiceCompat @@ -113,46 +114,41 @@ import kotlin.math.absoluteValue sealed class ServiceAction { data class GetDeviceMetadata(val destNum: Int) : ServiceAction() + data class Favorite(val node: Node) : ServiceAction() + data class Ignore(val node: Node) : ServiceAction() - data class Reaction(val emoji: String, val replyId: Int, val contactKey: String) : - ServiceAction() + + data class Reaction(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction() data class AddSharedContact(val contact: AdminProtos.SharedContact) : ServiceAction() } /** - * Handles all the communication with android apps. Also keeps an internal model - * of the network state. + * Handles all the communication with android apps. Also keeps an internal model of the network state. * - * Note: this service will go away once all clients are unbound from it. - * Warning: do not override toString, it causes infinite recursion on some androids (because contextWrapper.getResources calls to string + * Note: this service will go away once all clients are unbound from it. Warning: do not override toString, it causes + * infinite recursion on some androids (because contextWrapper.getResources calls to string */ @AndroidEntryPoint -class MeshService : Service(), Logging { - @Inject - lateinit var dispatchers: CoroutineDispatchers +class MeshService : + Service(), + Logging { + @Inject lateinit var dispatchers: CoroutineDispatchers - @Inject - lateinit var packetRepository: Lazy + @Inject lateinit var packetRepository: Lazy - @Inject - lateinit var meshLogRepository: Lazy + @Inject lateinit var meshLogRepository: Lazy - @Inject - lateinit var radioInterfaceService: RadioInterfaceService + @Inject lateinit var radioInterfaceService: RadioInterfaceService - @Inject - lateinit var locationRepository: LocationRepository + @Inject lateinit var locationRepository: LocationRepository - @Inject - lateinit var radioConfigRepository: RadioConfigRepository + @Inject lateinit var radioConfigRepository: RadioConfigRepository - @Inject - lateinit var mqttRepository: MQTTRepository + @Inject lateinit var mqttRepository: MQTTRepository - @Inject - lateinit var serviceNotifications: MeshServiceNotifications + @Inject lateinit var serviceNotifications: MeshServiceNotifications companion object : Logging { @@ -160,7 +156,8 @@ class MeshService : Service(), Logging { private fun actionReceived(portNum: String) = "$prefix.RECEIVED.$portNum" - // generate a RECEIVED action filter string that includes either the portnumber as an int, or preferably a symbolic name from portnums.proto + // generate a RECEIVED action filter string that includes either the portnumber as an int, or preferably a + // symbolic name from portnums.proto fun actionReceived(portNum: Int): String { val portType = Portnums.PortNum.forNumber(portNum) val portStr = portType?.toString() ?: portNum.toString() @@ -173,29 +170,30 @@ class MeshService : Service(), Logging { const val ACTION_MESSAGE_STATUS = "$prefix.MESSAGE_STATUS" open class NodeNotFoundException(reason: String) : Exception(reason) + class InvalidNodeIdException(id: String) : NodeNotFoundException("Invalid NodeId $id") + class NodeNumNotFoundException(id: Int) : NodeNotFoundException("NodeNum not found $id") + class IdNotFoundException(id: String) : NodeNotFoundException("ID not found $id") class NoDeviceConfigException(message: String = "No radio settings received (is our app too old?)") : RadioNotConnectedException(message) /** - * Talk to our running service and try to set a new device address. And then immediately - * call start on the service to possibly promote our service to be a foreground service. + * Talk to our running service and try to set a new device address. And then immediately call start on the + * service to possibly promote our service to be a foreground service. */ fun changeDeviceAddress(context: Context, service: IMeshService, address: String?) { service.setDeviceAddress(address) startService(context) } - fun createIntent() = Intent().setClassName( - "com.geeksville.mesh", - "com.geeksville.mesh.service.MeshService" - ) + fun createIntent() = Intent().setClassName("com.geeksville.mesh", "com.geeksville.mesh.service.MeshService") - /** The minimum firmware version we know how to talk to. We'll still be able - * to talk to 2.0 firmwares but only well enough to ask them to firmware update. + /** + * The minimum firmware version we know how to talk to. We'll still be able to talk to 2.0 firmwares but only + * well enough to ask them to firmware update. */ val minDeviceVersion = DeviceVersion(BuildConfig.MIN_FW_VERSION) val absoluteMinDeviceVersion = DeviceVersion(BuildConfig.ABS_MIN_FW_VERSION) @@ -208,6 +206,7 @@ class MeshService : Service(), Logging { ; fun isConnected() = this == CONNECTED + fun isDisconnected() = this == DISCONNECTED } @@ -216,9 +215,10 @@ class MeshService : Service(), Logging { // A mapping of receiver class name to package name - used for explicit broadcasts private val clientPackages = mutableMapOf() - private val serviceBroadcasts = MeshServiceBroadcasts(this, clientPackages) { - connectionState.also { radioConfigRepository.setConnectionState(it) } - } + private val serviceBroadcasts = + MeshServiceBroadcasts(this, clientPackages) { + connectionState.also { radioConfigRepository.setConnectionState(it) } + } private val serviceJob = Job() private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) private var connectionState = ConnectionState.DISCONNECTED @@ -239,44 +239,48 @@ class MeshService : Service(), Logging { } private val notificationSummary - get() = when (connectionState) { - ConnectionState.CONNECTED -> getString(R.string.connected_count).format( - numOnlineNodes - ) + get() = + when (connectionState) { + ConnectionState.CONNECTED -> getString(R.string.connected_count).format(numOnlineNodes) - ConnectionState.DISCONNECTED -> getString(R.string.disconnected) - ConnectionState.DEVICE_SLEEP -> getString(R.string.device_sleeping) - } + ConnectionState.DISCONNECTED -> getString(R.string.disconnected) + ConnectionState.DEVICE_SLEEP -> getString(R.string.device_sleeping) + } private var localStatsTelemetry: TelemetryProtos.Telemetry? = null - private val localStats: LocalStats? get() = localStatsTelemetry?.localStats - private val localStatsUpdatedAtMillis: Long? get() = localStatsTelemetry?.time?.let { it * 1000L } + private val localStats: LocalStats? + get() = localStatsTelemetry?.localStats - /** - * start our location requests (if they weren't already running) - */ + private val localStatsUpdatedAtMillis: Long? + get() = localStatsTelemetry?.time?.let { it * 1000L } + + /** start our location requests (if they weren't already running) */ private fun startLocationRequests() { // If we're already observing updates, don't register again if (locationFlow?.isActive == true) return @SuppressLint("MissingPermission") if (hasLocationPermission()) { - locationFlow = locationRepository.getLocations().onEach { location -> - sendPosition( - position { - latitudeI = Position.degI(location.latitude) - longitudeI = Position.degI(location.longitude) - if (LocationCompat.hasMslAltitude(location)) { - altitude = LocationCompat.getMslAltitudeMeters(location).toInt() - } - altitudeHae = location.altitude.toInt() - time = (location.time / 1000).toInt() - groundSpeed = location.speed.toInt() - groundTrack = location.bearing.toInt() - locationSource = MeshProtos.Position.LocSource.LOC_EXTERNAL + locationFlow = + locationRepository + .getLocations() + .onEach { location -> + sendPosition( + position { + latitudeI = Position.degI(location.latitude) + longitudeI = Position.degI(location.longitude) + if (LocationCompat.hasMslAltitude(location)) { + altitude = LocationCompat.getMslAltitudeMeters(location).toInt() + } + altitudeHae = location.altitude.toInt() + time = (location.time / 1000).toInt() + groundSpeed = location.speed.toInt() + groundTrack = location.bearing.toInt() + locationSource = MeshProtos.Position.LocSource.LOC_EXTERNAL + }, + ) } - ) - }.launchIn(serviceScope) + .launchIn(serviceScope) } } @@ -288,8 +292,9 @@ class MeshService : Service(), Logging { } } - /** Send a command/packet to our radio. But cope with the possibility that we might start up - before we are fully bound to the RadioInterfaceService + /** + * Send a command/packet to our radio. But cope with the possibility that we might start up before we are fully + * bound to the RadioInterfaceService */ private fun sendToRadio(p: ToRadio.Builder) { val built = p.build() @@ -300,21 +305,23 @@ class MeshService : Service(), Logging { changeStatus(p.packet.id, MessageStatus.ENROUTE) if (p.packet.hasDecoded()) { - val packetToSave = MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "Packet", - received_date = System.currentTimeMillis(), - raw_message = p.packet.toString(), - fromNum = p.packet.from, - portNum = p.packet.decoded.portnumValue, - fromRadio = fromRadio { packet = p.packet }, - ) + val packetToSave = + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "Packet", + received_date = System.currentTimeMillis(), + raw_message = p.packet.toString(), + fromNum = p.packet.from, + portNum = p.packet.decoded.portnumValue, + fromRadio = fromRadio { packet = p.packet }, + ) insertMeshLog(packetToSave) } } /** - * Send a mesh packet to the radio, if the radio is not currently connected this function will throw NotConnectedException + * Send a mesh packet to the radio, if the radio is not currently connected this function will throw + * NotConnectedException */ private fun sendToRadio(packet: MeshPacket) { queuedPackets.add(packet) @@ -325,24 +332,25 @@ class MeshService : Service(), Logging { serviceNotifications.showAlertNotification( contactKey, getSenderName(dataPacket), - dataPacket.alert ?: getString(R.string.critical_alert) + dataPacket.alert ?: getString(R.string.critical_alert), ) } private fun updateMessageNotification(contactKey: String, dataPacket: DataPacket) { - val message: String = when (dataPacket.dataType) { - Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> dataPacket.text!! - Portnums.PortNum.WAYPOINT_APP_VALUE -> { - getString(R.string.waypoint_received, dataPacket.waypoint!!.name) - } + val message: String = + when (dataPacket.dataType) { + Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> dataPacket.text!! + Portnums.PortNum.WAYPOINT_APP_VALUE -> { + getString(R.string.waypoint_received, dataPacket.waypoint!!.name) + } - else -> return - } + else -> return + } serviceNotifications.updateMessageNotification( contactKey, getSenderName(dataPacket), message, - isBroadcast = dataPacket.to == DataPacket.ID_BROADCAST + isBroadcast = dataPacket.to == DataPacket.ID_BROADCAST, ) } @@ -354,36 +362,37 @@ class MeshService : Service(), Logging { info("Creating mesh service") serviceNotifications.initChannels() // Switch to the IO thread - serviceScope.handledLaunch { - radioInterfaceService.connect() - } - radioInterfaceService.connectionState.onEach(::onRadioConnectionState) - .launchIn(serviceScope) - radioInterfaceService.receivedData.onEach(::onReceiveFromRadio) - .launchIn(serviceScope) - radioConfigRepository.localConfigFlow.onEach { localConfig = it } - .launchIn(serviceScope) - radioConfigRepository.moduleConfigFlow.onEach { moduleConfig = it } - .launchIn(serviceScope) - radioConfigRepository.channelSetFlow.onEach { channelSet = it } - .launchIn(serviceScope) - radioConfigRepository.serviceAction.onEach(::onServiceAction) - .launchIn(serviceScope) + serviceScope.handledLaunch { radioInterfaceService.connect() } + radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(serviceScope) + radioInterfaceService.receivedData.onEach(::onReceiveFromRadio).launchIn(serviceScope) + radioConfigRepository.localConfigFlow.onEach { localConfig = it }.launchIn(serviceScope) + radioConfigRepository.moduleConfigFlow.onEach { moduleConfig = it }.launchIn(serviceScope) + radioConfigRepository.channelSetFlow.onEach { channelSet = it }.launchIn(serviceScope) + radioConfigRepository.serviceAction.onEach(::onServiceAction).launchIn(serviceScope) loadSettings() // Load our last known node DB // the rest of our init will happen once we are in radioConnection.onServiceConnected } - /** - * If someone binds to us, this will be called after on create - */ - override fun onBind(intent: Intent?): IBinder { - return binder - } + /** If someone binds to us, this will be called after on create */ + override fun onBind(intent: Intent?): IBinder = binder /** - * If someone starts us (or restarts us) this will be called after onCreate) + * Called when the service is started or restarted. This method manages the foreground state of the service. + * + * It attempts to start the service in the foreground with a notification. If `startForeground` fails, for example, + * due to a `SecurityException` on Android 13+ because the `POST_NOTIFICATIONS` permission is missing, it logs an + * error* and returns `START_NOT_STICKY` to prevent the service from becoming sticky in a broken state. + * + * If the service is not intended to be in the foreground (e.g., no device is connected), it stops the foreground + * state and returns `START_NOT_STICKY`. Otherwise, it returns `START_STICKY`. + * + * @param intent The Intent supplied to `startService(Intent)`, as modified by the system. + * @param flags Additional data about this start request. + * @param startId A unique integer representing this specific request to start. + * @return The return value indicates what semantics the system should use for the service's current started state. + * See [Service.onStartCommand] for details. */ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val a = radioInterfaceService.getBondedDeviceAddress() @@ -391,7 +400,8 @@ class MeshService : Service(), Logging { info("Requesting foreground service=$wantForeground") - // We always start foreground because that's how our service is always started (if we didn't then android would kill us) + // We always start foreground because that's how our service is always started (if we didn't then android would + // kill us) // but if we don't really need foreground we immediately stop it. val notification = serviceNotifications.createServiceStateNotification(notificationSummary) @@ -410,8 +420,15 @@ class MeshService : Service(), Logging { 0 }, ) - } catch (ex: Exception) { - errormsg("startForeground failed", ex) + } catch (ex: SecurityException) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + errormsg( + "startForeground failed, likely due to missing POST_NOTIFICATIONS permission on Android 13+", + ex, + ) + } else { + errormsg("startForeground failed", ex) + } return START_NOT_STICKY } return if (!wantForeground) { @@ -440,12 +457,11 @@ class MeshService : Service(), Logging { discardNodeDB() // Get rid of any old state myNodeInfo = radioConfigRepository.myNodeInfo.value nodeDBbyNodeNum.putAll(radioConfigRepository.getNodeDBbyNum()) - // Note: we do not haveNodeDB = true because that means we've got a valid db from a real device (rather than this possibly stale hint) + // Note: we do not haveNodeDB = true because that means we've got a valid db from a real device (rather than + // this possibly stale hint) } - /** - * discard entire node db & message state - used when downloading a new db from the device - */ + /** discard entire node db & message state - used when downloading a new db from the device */ private fun discardNodeDB() { debug("Discarding NodeDB") myNodeInfo = null @@ -464,37 +480,42 @@ class MeshService : Service(), Logging { private var channelSet: AppOnlyProtos.ChannelSet = AppOnlyProtos.ChannelSet.getDefaultInstance() // True after we've done our initial node db init - @Volatile - private var haveNodeDB = false + @Volatile private var haveNodeDB = false // The database of active nodes, index is the node number private val nodeDBbyNodeNum = ConcurrentHashMap() // The database of active nodes, index is the node user ID string // NOTE: some NodeInfos might be in only nodeDBbyNodeNum (because we don't yet know an ID). - private val nodeDBbyID get() = nodeDBbyNodeNum.mapKeys { it.value.user.id } + private val nodeDBbyID + get() = nodeDBbyNodeNum.mapKeys { it.value.user.id } // // END OF MODEL // - private val deviceVersion get() = DeviceVersion(myNodeInfo?.firmwareVersion ?: "") - private val appVersion get() = BuildConfig.VERSION_CODE - private val minAppVersion get() = myNodeInfo?.minAppVersion ?: 0 + private val deviceVersion + get() = DeviceVersion(myNodeInfo?.firmwareVersion ?: "") + + private val appVersion + get() = BuildConfig.VERSION_CODE + + private val minAppVersion + get() = myNodeInfo?.minAppVersion ?: 0 // Map a nodenum to a node, or throw an exception if not found private fun toNodeInfo(n: Int) = nodeDBbyNodeNum[n] ?: throw NodeNumNotFoundException(n) - /** Map a nodeNum to the nodeId string - If we have a NodeInfo for this ID we prefer to return the string ID inside the user record. - but some nodes might not have a user record at all (because not yet received), in that case, we return - a hex version of the ID just based on the number */ - private fun toNodeID(n: Int): String = - if (n == DataPacket.NODENUM_BROADCAST) { - DataPacket.ID_BROADCAST - } else { - nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n) - } + /** + * Map a nodeNum to the nodeId string If we have a NodeInfo for this ID we prefer to return the string ID inside the + * user record. but some nodes might not have a user record at all (because not yet received), in that case, we + * return a hex version of the ID just based on the number + */ + private fun toNodeID(n: Int): String = if (n == DataPacket.NODENUM_BROADCAST) { + DataPacket.ID_BROADCAST + } else { + nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n) + } // given a nodeNum, return a db entry - creating if necessary private fun getOrCreateNodeInfo(n: Int, channel: Int = 0) = nodeDBbyNodeNum.getOrPut(n) { @@ -506,42 +527,38 @@ class MeshService : Service(), Logging { hwModel = MeshProtos.HardwareModel.UNSET } - NodeEntity( - num = n, - user = defaultUser, - longName = defaultUser.longName, - channel = channel, - ) + NodeEntity(num = n, user = defaultUser, longName = defaultUser.longName, channel = channel) } private val hexIdRegex = """\!([0-9A-Fa-f]+)""".toRegex() // Map a userid to a node/ node num, or throw an exception if not found - // We prefer to find nodes based on their assigned IDs, but if no ID has been assigned to a node, we can also find it based on node number + // We prefer to find nodes based on their assigned IDs, but if no ID has been assigned to a node, we can also find + // it based on node number private fun toNodeInfo(id: String): NodeEntity { // If this is a valid hexaddr will be !null val hexStr = hexIdRegex.matchEntire(id)?.groups?.get(1)?.value - return nodeDBbyID[id] ?: when { - id == DataPacket.ID_LOCAL -> toNodeInfo(myNodeNum) - hexStr != null -> { - val n = hexStr.toLong(16).toInt() - nodeDBbyNodeNum[n] ?: throw IdNotFoundException(id) - } + return nodeDBbyID[id] + ?: when { + id == DataPacket.ID_LOCAL -> toNodeInfo(myNodeNum) + hexStr != null -> { + val n = hexStr.toLong(16).toInt() + nodeDBbyNodeNum[n] ?: throw IdNotFoundException(id) + } - else -> throw InvalidNodeIdException(id) - } + else -> throw InvalidNodeIdException(id) + } } - private fun getUserName(num: Int): String = - with(radioConfigRepository.getUser(num)) { "$longName ($shortName)" } + private fun getUserName(num: Int): String = with(radioConfigRepository.getUser(num)) { "$longName ($shortName)" } - private val numNodes get() = nodeDBbyNodeNum.size + private val numNodes + get() = nodeDBbyNodeNum.size - /** - * How many nodes are currently online (including our local node) - */ - private val numOnlineNodes get() = nodeDBbyNodeNum.values.count { it.isOnline } + /** How many nodes are currently online (including our local node) */ + private val numOnlineNodes + get() = nodeDBbyNodeNum.values.count { it.isOnline } private fun toNodeNum(id: String): Int = when (id) { DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST @@ -560,9 +577,7 @@ class MeshService : Service(), Logging { updateFn(info) if (info.user.id.isNotEmpty() && haveNodeDB) { - serviceScope.handledLaunch { - radioConfigRepository.upsert(info) - } + serviceScope.handledLaunch { radioConfigRepository.upsert(info) } } if (withBroadcast) { @@ -572,23 +587,23 @@ class MeshService : Service(), Logging { // My node num private val myNodeNum - get() = myNodeInfo?.myNodeNum - ?: throw RadioNotConnectedException("We don't yet have our myNodeInfo") + get() = myNodeInfo?.myNodeNum ?: throw RadioNotConnectedException("We don't yet have our myNodeInfo") // My node ID string - private val myNodeID get() = toNodeID(myNodeNum) + private val myNodeID + get() = toNodeID(myNodeNum) // Admin channel index private val MeshPacket.Builder.adminChannelIndex: Int - get() = when { - myNodeNum == to -> 0 - nodeDBbyNodeNum[myNodeNum]?.hasPKC == true && nodeDBbyNodeNum[to]?.hasPKC == true -> - DataPacket.PKC_CHANNEL_INDEX + get() = + when { + myNodeNum == to -> 0 + nodeDBbyNodeNum[myNodeNum]?.hasPKC == true && nodeDBbyNodeNum[to]?.hasPKC == true -> + DataPacket.PKC_CHANNEL_INDEX - else -> channelSet.settingsList - .indexOfFirst { it.name.equals("admin", ignoreCase = true) } - .coerceAtLeast(0) - } + else -> + channelSet.settingsList.indexOfFirst { it.name.equals("admin", ignoreCase = true) }.coerceAtLeast(0) + } // Generate a new mesh packet builder with our node as the sender, and the specified node num private fun newMeshPacketTo(idNum: Int) = MeshPacket.newBuilder().apply { @@ -608,29 +623,23 @@ class MeshService : Service(), Logging { */ private fun newMeshPacketTo(id: String) = newMeshPacketTo(toNodeNum(id)) - /** - * Helper to make it easy to build a subpacket in the proper protobufs - */ + /** Helper to make it easy to build a subpacket in the proper protobufs */ private fun MeshPacket.Builder.buildMeshPacket( wantAck: Boolean = false, id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one hopLimit: Int = localConfig.lora.hopLimit, channel: Int = 0, priority: MeshPacket.Priority = MeshPacket.Priority.UNSET, - initFn: MeshProtos.Data.Builder.() -> Unit + initFn: MeshProtos.Data.Builder.() -> Unit, ): MeshPacket { this.wantAck = wantAck this.id = id this.hopLimit = hopLimit this.priority = priority - decoded = MeshProtos.Data.newBuilder().also { - initFn(it) - }.build() + decoded = MeshProtos.Data.newBuilder().also { initFn(it) }.build() if (channel == DataPacket.PKC_CHANNEL_INDEX) { pkiEncrypted = true - nodeDBbyNodeNum[to]?.user?.publicKey?.let { publicKey -> - this.publicKey = publicKey - } + nodeDBbyNodeNum[to]?.user?.publicKey?.let { publicKey -> this.publicKey = publicKey } } else { this.channel = channel } @@ -638,88 +647,81 @@ class MeshService : Service(), Logging { return build() } - /** - * Helper to make it easy to build a subpacket in the proper protobufs - */ + /** Helper to make it easy to build a subpacket in the proper protobufs */ private fun MeshPacket.Builder.buildAdminPacket( id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one wantResponse: Boolean = false, - initFn: AdminProtos.AdminMessage.Builder.() -> Unit - ): MeshPacket = buildMeshPacket( - id = id, - wantAck = true, - channel = adminChannelIndex, - priority = MeshPacket.Priority.RELIABLE - ) { - this.wantResponse = wantResponse - portnumValue = Portnums.PortNum.ADMIN_APP_VALUE - payload = AdminProtos.AdminMessage.newBuilder().also { - initFn(it) - it.sessionPasskey = sessionPasskey - }.build().toByteString() - } + initFn: AdminProtos.AdminMessage.Builder.() -> Unit, + ): MeshPacket = + buildMeshPacket(id = id, wantAck = true, channel = adminChannelIndex, priority = MeshPacket.Priority.RELIABLE) { + this.wantResponse = wantResponse + portnumValue = Portnums.PortNum.ADMIN_APP_VALUE + payload = + AdminProtos.AdminMessage.newBuilder() + .also { + initFn(it) + it.sessionPasskey = sessionPasskey + } + .build() + .toByteString() + } // Generate a DataPacket from a MeshPacket, or null if we didn't have enough data to do so - private fun toDataPacket(packet: MeshPacket): DataPacket? { - return if (!packet.hasDecoded()) { - // We never convert packets that are not DataPackets - null - } else { - val data = packet.decoded + private fun toDataPacket(packet: MeshPacket): DataPacket? = if (!packet.hasDecoded()) { + // We never convert packets that are not DataPackets + null + } else { + val data = packet.decoded - DataPacket( - from = toNodeID(packet.from), - to = toNodeID(packet.to), - time = packet.rxTime * 1000L, - id = packet.id, - dataType = data.portnumValue, - bytes = data.payload.toByteArray(), - hopLimit = packet.hopLimit, - channel = if (packet.pkiEncrypted) DataPacket.PKC_CHANNEL_INDEX else packet.channel, - wantAck = packet.wantAck, - hopStart = packet.hopStart, - snr = packet.rxSnr, - rssi = packet.rxRssi, - replyId = data.replyId, - ) + DataPacket( + from = toNodeID(packet.from), + to = toNodeID(packet.to), + time = packet.rxTime * 1000L, + id = packet.id, + dataType = data.portnumValue, + bytes = data.payload.toByteArray(), + hopLimit = packet.hopLimit, + channel = if (packet.pkiEncrypted) DataPacket.PKC_CHANNEL_INDEX else packet.channel, + wantAck = packet.wantAck, + hopStart = packet.hopStart, + snr = packet.rxSnr, + rssi = packet.rxRssi, + replyId = data.replyId, + ) + } + + private fun toMeshPacket(p: DataPacket): MeshPacket = newMeshPacketTo(p.to!!).buildMeshPacket( + id = p.id, + wantAck = p.wantAck, + hopLimit = p.hopLimit, + channel = p.channel, + ) { + portnumValue = p.dataType + payload = ByteString.copyFrom(p.bytes) + if (p.replyId != null && p.replyId != 0) { + this.replyId = p.replyId!! } } - private fun toMeshPacket(p: DataPacket): MeshPacket { - return newMeshPacketTo(p.to!!).buildMeshPacket( - id = p.id, - wantAck = p.wantAck, - hopLimit = p.hopLimit, - channel = p.channel, - ) { - portnumValue = p.dataType - payload = ByteString.copyFrom(p.bytes) - if (p.replyId != null && p.replyId != 0) { - this.replyId = p.replyId!! - } - } - } - - private val rememberDataType = setOf( - Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, - Portnums.PortNum.ALERT_APP_VALUE, - Portnums.PortNum.WAYPOINT_APP_VALUE, - ) + private val rememberDataType = + setOf( + Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + Portnums.PortNum.ALERT_APP_VALUE, + Portnums.PortNum.WAYPOINT_APP_VALUE, + ) private fun rememberReaction(packet: MeshPacket) = serviceScope.handledLaunch { - val reaction = ReactionEntity( - replyId = packet.decoded.replyId, - userId = toNodeID(packet.from), - emoji = packet.decoded.payload.toByteArray().decodeToString(), - timestamp = System.currentTimeMillis(), - ) + val reaction = + ReactionEntity( + replyId = packet.decoded.replyId, + userId = toNodeID(packet.from), + emoji = packet.decoded.payload.toByteArray().decodeToString(), + timestamp = System.currentTimeMillis(), + ) packetRepository.get().insertReaction(reaction) } - private fun rememberDataPacket( - dataPacket: DataPacket, - updateNotification: Boolean = true, - ) { + private fun rememberDataPacket(dataPacket: DataPacket, updateNotification: Boolean = true) { if (dataPacket.dataType !in rememberDataType) return val fromLocal = dataPacket.from == DataPacket.ID_LOCAL val toBroadcast = dataPacket.to == DataPacket.ID_BROADCAST @@ -728,20 +730,21 @@ class MeshService : Service(), Logging { // contactKey: unique contact key filter (channel)+(nodeId) val contactKey = "${dataPacket.channel}$contactId" - val packetToSave = Packet( - uuid = 0L, // autoGenerated - myNodeNum = myNodeNum, - packetId = dataPacket.id, - port_num = dataPacket.dataType, - contact_key = contactKey, - received_time = System.currentTimeMillis(), - read = fromLocal, - data = dataPacket, - snr = dataPacket.snr, - rssi = dataPacket.rssi, - hopsAway = dataPacket.hopsAway, - replyId = dataPacket.replyId ?: 0 - ) + val packetToSave = + Packet( + uuid = 0L, // autoGenerated + myNodeNum = myNodeNum, + packetId = dataPacket.id, + port_num = dataPacket.dataType, + contact_key = contactKey, + received_time = System.currentTimeMillis(), + read = fromLocal, + data = dataPacket, + snr = dataPacket.snr, + rssi = dataPacket.rssi, + hopsAway = dataPacket.hopsAway, + replyId = dataPacket.replyId ?: 0, + ) serviceScope.handledLaunch { packetRepository.get().apply { insert(packetToSave) @@ -764,7 +767,6 @@ class MeshService : Service(), Logging { val dataPacket = toDataPacket(packet) if (dataPacket != null) { - // We ignore most messages that we sent val fromUs = myInfo.myNodeNum == packet.from @@ -815,17 +817,20 @@ class MeshService : Service(), Logging { Portnums.PortNum.NODEINFO_APP_VALUE -> if (!fromUs) { - val u = MeshProtos.User.parseFrom(data.payload).copy { - if (isLicensed) clearPublicKey() - if (packet.viaMqtt) longName = "$longName (MQTT)" - } + val u = + MeshProtos.User.parseFrom(data.payload).copy { + if (isLicensed) clearPublicKey() + if (packet.viaMqtt) longName = "$longName (MQTT)" + } handleReceivedUser(packet.from, u, packet.channel) } // Handle new telemetry info Portnums.PortNum.TELEMETRY_APP_VALUE -> { - val u = TelemetryProtos.Telemetry.parseFrom(data.payload) - .copy { if (time == 0) time = (dataPacket.time / 1000L).toInt() } + val u = + TelemetryProtos.Telemetry.parseFrom(data.payload).copy { + if (time == 0) time = (dataPacket.time / 1000L).toInt() + } handleReceivedTelemetry(packet.from, u) } @@ -872,9 +877,7 @@ class MeshService : Service(), Logging { } Portnums.PortNum.TRACEROUTE_APP_VALUE -> { - radioConfigRepository.setTracerouteResponse( - packet.getTracerouteResponse(::getUserName) - ) + radioConfigRepository.setTracerouteResponse(packet.getTracerouteResponse(::getUserName)) } else -> debug("No custom processing needed for ${data.portnumValue}") @@ -885,15 +888,12 @@ class MeshService : Service(), Logging { serviceBroadcasts.broadcastReceivedData(dataPacket) } - GeeksvilleApplication.analytics.track( - "num_data_receive", - DataPair(1) - ) + GeeksvilleApplication.analytics.track("num_data_receive", DataPair(1)) GeeksvilleApplication.analytics.track( "data_receive", DataPair("num_bytes", bytes.size), - DataPair("type", data.portnumValue) + DataPair("type", data.portnumValue), ) } } @@ -942,14 +942,15 @@ class MeshService : Service(), Logging { val newNode = (it.isUnknownUser && p.hwModel != MeshProtos.HardwareModel.UNSET) val keyMatch = !it.hasPKC || it.user.publicKey == p.publicKey - it.user = if (keyMatch) { - p - } else { - p.copy { - warn("Public key mismatch from $longName ($shortName)") - publicKey = NodeEntity.ERROR_BYTE_STRING + it.user = + if (keyMatch) { + p + } else { + p.copy { + warn("Public key mismatch from $longName ($shortName)") + publicKey = NodeEntity.ERROR_BYTE_STRING + } } - } it.longName = p.longName it.shortName = p.shortName it.channel = channel @@ -959,16 +960,20 @@ class MeshService : Service(), Logging { } } - /** Update our DB of users based on someone sending out a Position subpacket + /** + * Update our DB of users based on someone sending out a Position subpacket + * * @param defaultTime in msecs since 1970 */ private fun handleReceivedPosition( fromNum: Int, p: MeshProtos.Position, - defaultTime: Long = System.currentTimeMillis() + defaultTime: Long = System.currentTimeMillis(), ) { - // Nodes periodically send out position updates, but those updates might not contain a lat & lon (because no GPS lock) - // We like to look at the local node to see if it has been sending out valid lat/lon, so for the LOCAL node (only) + // Nodes periodically send out position updates, but those updates might not contain a lat & lon (because no GPS + // lock) + // We like to look at the local node to see if it has been sending out valid lat/lon, so for the LOCAL node + // (only) // we don't record these nop position updates if (myNodeNum == fromNum && p.latitudeI == 0 && p.longitudeI == 0) { debug("Ignoring nop position update for the local node") @@ -981,10 +986,7 @@ class MeshService : Service(), Logging { } // Update our DB of users based on someone sending out a Telemetry subpacket - private fun handleReceivedTelemetry( - fromNum: Int, - t: TelemetryProtos.Telemetry, - ) { + private fun handleReceivedTelemetry(fromNum: Int, t: TelemetryProtos.Telemetry) { val isRemote = (fromNum != myNodeNum) if (!isRemote && t.hasLocalStats()) { localStatsTelemetry = t @@ -995,14 +997,12 @@ class MeshService : Service(), Logging { t.hasDeviceMetrics() -> { it.deviceTelemetry = t if (fromNum == myNodeNum || (isRemote && it.isFavorite)) { - if (t.deviceMetrics.voltage > batteryPercentUnsupported && + if ( + t.deviceMetrics.voltage > batteryPercentUnsupported && t.deviceMetrics.batteryLevel <= batteryPercentLowThreshold ) { if (shouldBatteryNotificationShow(fromNum, t)) { - serviceNotifications.showOrUpdateLowBatteryNotification( - it, - isRemote - ) + serviceNotifications.showOrUpdateLowBatteryNotification(it, isRemote) } } else { if (batteryPercentCooldowns.containsKey(fromNum)) { @@ -1030,17 +1030,14 @@ class MeshService : Service(), Logging { } t.deviceMetrics.batteryLevel == batteryPercentLowThreshold -> shouldDisplay = true - t.deviceMetrics.batteryLevel.mod(batteryPercentLowDivisor) == 0 && !isRemote -> shouldDisplay = - true + t.deviceMetrics.batteryLevel.mod(batteryPercentLowDivisor) == 0 && !isRemote -> shouldDisplay = true isRemote -> shouldDisplay = true } if (shouldDisplay) { val now = System.currentTimeMillis() / 1000 if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0 - if ((now - batteryPercentCooldowns[fromNum]!!) >= batteryPercentCooldownSeconds || - forceDisplay - ) { + if ((now - batteryPercentCooldowns[fromNum]!!) >= batteryPercentCooldownSeconds || forceDisplay) { batteryPercentCooldowns[fromNum] = now return true } @@ -1052,30 +1049,31 @@ class MeshService : Service(), Logging { updateNodeInfo(fromNum) { it.paxcounter = p } } - private fun handleReceivedStoreAndForward( - dataPacket: DataPacket, - s: StoreAndForwardProtos.StoreAndForward, - ) { + private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForwardProtos.StoreAndForward) { debug("StoreAndForward: ${s.variantCase} ${s.rr} from ${dataPacket.from}") when (s.variantCase) { StoreAndForwardProtos.StoreAndForward.VariantCase.STATS -> { - val u = dataPacket.copy( - bytes = s.stats.toString().encodeToByteArray(), - dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE - ) + val u = + dataPacket.copy( + bytes = s.stats.toString().encodeToByteArray(), + dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + ) rememberDataPacket(u) } StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY -> { - val text = """ + val text = + """ Total messages: ${s.history.historyMessages} History window: ${s.history.window / 60000} min Last request: ${s.history.lastRequest} - """.trimIndent() - val u = dataPacket.copy( - bytes = text.encodeToByteArray(), - dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE - ) + """ + .trimIndent() + val u = + dataPacket.copy( + bytes = text.encodeToByteArray(), + dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + ) rememberDataPacket(u) } @@ -1083,10 +1081,8 @@ class MeshService : Service(), Logging { if (s.rr == StoreAndForwardProtos.StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) { dataPacket.to = DataPacket.ID_BROADCAST } - val u = dataPacket.copy( - bytes = s.text.toByteArray(), - dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, - ) + val u = + dataPacket.copy(bytes = s.text.toByteArray(), dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) rememberDataPacket(u) } @@ -1100,15 +1096,21 @@ class MeshService : Service(), Logging { // Update our model and resend as needed for a MeshPacket we just received from the radio private fun handleReceivedMeshPacket(packet: MeshPacket) { if (haveNodeDB) { - processReceivedMeshPacket(packet.toBuilder().apply { - // If the rxTime was not set by the device, update with current time - if (packet.rxTime == 0) setRxTime(currentSecond()) - }.build()) + processReceivedMeshPacket( + packet + .toBuilder() + .apply { + // If the rxTime was not set by the device, update with current time + if (packet.rxTime == 0) setRxTime(currentSecond()) + } + .build(), + ) onNodeDBChanged() } else { warn("Ignoring early received packet: ${packet.toOneLineString()}") // earlyReceivedPackets.add(packet) - // logAssert(earlyReceivedPackets.size < 128) // The max should normally be about 32, but if the device is messed up it might try to send forever + // logAssert(earlyReceivedPackets.size < 128) // The max should normally be about 32, but if the device is + // messed up it might try to send forever } } @@ -1122,9 +1124,7 @@ class MeshService : Service(), Logging { queueResponse[packet.id] = future try { if (connectionState != ConnectionState.CONNECTED) throw RadioNotConnectedException() - sendToRadio(ToRadio.newBuilder().apply { - this.packet = packet - }) + sendToRadio(ToRadio.newBuilder().apply { this.packet = packet }) } catch (ex: Exception) { errormsg("sendToRadio error:", ex) future.complete(false) @@ -1134,24 +1134,25 @@ class MeshService : Service(), Logging { private fun startPacketQueue() { if (queueJob?.isActive == true) return - queueJob = serviceScope.handledLaunch { - debug("packet queueJob started") - while (connectionState == ConnectionState.CONNECTED) { - // take the first packet from the queue head - val packet = queuedPackets.poll() ?: break - try { - // send packet to the radio and wait for response - val response = sendPacket(packet) - debug("queueJob packet id=${packet.id.toUInt()} waiting") - val success = response.get(2, TimeUnit.MINUTES) - debug("queueJob packet id=${packet.id.toUInt()} success $success") - } catch (e: TimeoutException) { - debug("queueJob packet id=${packet.id.toUInt()} timeout") - } catch (e: Exception) { - debug("queueJob packet id=${packet.id.toUInt()} failed") + queueJob = + serviceScope.handledLaunch { + debug("packet queueJob started") + while (connectionState == ConnectionState.CONNECTED) { + // take the first packet from the queue head + val packet = queuedPackets.poll() ?: break + try { + // send packet to the radio and wait for response + val response = sendPacket(packet) + debug("queueJob packet id=${packet.id.toUInt()} waiting") + val success = response.get(2, TimeUnit.MINUTES) + debug("queueJob packet id=${packet.id.toUInt()} success $success") + } catch (e: TimeoutException) { + debug("queueJob packet id=${packet.id.toUInt()} timeout") + } catch (e: Exception) { + debug("queueJob packet id=${packet.id.toUInt()} failed") + } } } - } } private fun stopPacketQueue() { @@ -1194,30 +1195,29 @@ class MeshService : Service(), Logging { dataPacket } - /** - * Change the status on a DataPacket and update watchers - */ + /** Change the status on a DataPacket and update watchers */ private fun changeStatus(packetId: Int, m: MessageStatus) = serviceScope.handledLaunch { - if (packetId != 0) getDataPacketById(packetId)?.let { p -> - if (p.status == m) return@handledLaunch - packetRepository.get().updateMessageStatus(p, m) - serviceBroadcasts.broadcastMessageStatus(packetId, m) + if (packetId != 0) { + getDataPacketById(packetId)?.let { p -> + if (p.status == m) return@handledLaunch + packetRepository.get().updateMessageStatus(p, m) + serviceBroadcasts.broadcastMessageStatus(packetId, m) + } } } - /** - * Handle an ack/nak packet by updating sent message status - */ + /** Handle an ack/nak packet by updating sent message status */ private fun handleAckNak(requestId: Int, fromId: String, routingError: Int) { serviceScope.handledLaunch { val isAck = routingError == MeshProtos.Routing.Error.NONE_VALUE val p = packetRepository.get().getPacketById(requestId) // distinguish real ACKs coming from the intended receiver - val m = when { - isAck && fromId == p?.data?.to -> MessageStatus.RECEIVED - isAck -> MessageStatus.DELIVERED - else -> MessageStatus.ERROR - } + val m = + when { + isAck && fromId == p?.data?.to -> MessageStatus.RECEIVED + isAck -> MessageStatus.DELIVERED + else -> MessageStatus.ERROR + } if (p != null && p.data.status != MessageStatus.RECEIVED) { p.data.status = m p.routingError = routingError @@ -1237,43 +1237,44 @@ class MeshService : Service(), Logging { // debug("Received: $packet") if (packet.hasDecoded()) { - val packetToSave = MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "Packet", - received_date = System.currentTimeMillis(), - raw_message = packet.toString(), - fromNum = packet.from, - portNum = packet.decoded.portnumValue, - fromRadio = fromRadio { this.packet = packet }, - ) + val packetToSave = + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "Packet", + received_date = System.currentTimeMillis(), + raw_message = packet.toString(), + fromNum = packet.from, + portNum = packet.decoded.portnumValue, + fromRadio = fromRadio { this.packet = packet }, + ) insertMeshLog(packetToSave) - serviceScope.handledLaunch { - radioConfigRepository.emitMeshPacket(packet) - } + serviceScope.handledLaunch { radioConfigRepository.emitMeshPacket(packet) } - // Update last seen for the node that sent the packet, but also for _our node_ because anytime a packet passes + // Update last seen for the node that sent the packet, but also for _our node_ because anytime a packet + // passes // through our node on the way to the phone that means that local node is also alive in the mesh val isOtherNode = myNodeNum != fromNum - updateNodeInfo(myNodeNum, withBroadcast = isOtherNode) { - it.lastHeard = currentSecond() - } + updateNodeInfo(myNodeNum, withBroadcast = isOtherNode) { it.lastHeard = currentSecond() } // Do not generate redundant broadcasts of node change for this bookkeeping updateNodeInfo call - // because apps really only care about important updates of node state - which handledReceivedData will give them + // because apps really only care about important updates of node state - which handledReceivedData will give + // them updateNodeInfo(fromNum, withBroadcast = false, channel = packet.channel) { - // Update our last seen based on any valid timestamps. If the device didn't provide a timestamp make one + // Update our last seen based on any valid timestamps. If the device didn't provide a timestamp make + // one it.lastHeard = packet.rxTime it.snr = packet.rxSnr it.rssi = packet.rxRssi // Generate our own hopsAway, comparing hopStart to hopLimit. - it.hopsAway = if (packet.hopStart == 0 || packet.hopLimit > packet.hopStart) { - -1 - } else { - packet.hopStart - packet.hopLimit - } + it.hopsAway = + if (packet.hopStart == 0 || packet.hopLimit > packet.hopStart) { + -1 + } else { + packet.hopStart - packet.hopLimit + } } handleReceivedData(packet) } @@ -1288,20 +1289,15 @@ class MeshService : Service(), Logging { } private fun setLocalConfig(config: ConfigProtos.Config) { - serviceScope.handledLaunch { - radioConfigRepository.setLocalConfig(config) - } + serviceScope.handledLaunch { radioConfigRepository.setLocalConfig(config) } } private fun setLocalModuleConfig(config: ModuleConfigProtos.ModuleConfig) { - serviceScope.handledLaunch { - radioConfigRepository.setLocalModuleConfig(config) - } + serviceScope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) } } - private fun updateChannelSettings(ch: ChannelProtos.Channel) = serviceScope.handledLaunch { - radioConfigRepository.updateChannelSettings(ch) - } + private fun updateChannelSettings(ch: ChannelProtos.Channel) = + serviceScope.handledLaunch { radioConfigRepository.updateChannelSettings(ch) } private fun currentSecond() = (System.currentTimeMillis() / 1000).toInt() @@ -1310,25 +1306,20 @@ class MeshService : Service(), Logging { maybeUpdateServiceStatusNotification() } - /** - * Send in analytics about mesh connection - */ + /** Send in analytics about mesh connection */ private fun reportConnection() { val radioModel = DataPair("radio_model", myNodeInfo?.model ?: "unknown") GeeksvilleApplication.analytics.track( "mesh_connect", DataPair("num_nodes", numNodes), DataPair("num_online", numOnlineNodes), - radioModel + radioModel, ) // Once someone connects to hardware start tracking the approximate number of nodes in their mesh // this allows us to collect stats on what typical mesh size is and to tell difference between users who just // downloaded the app, vs has connected it to some hardware. - GeeksvilleApplication.analytics.setUserInfo( - DataPair("num_nodes", numNodes), - radioModel - ) + GeeksvilleApplication.analytics.setUserInfo(DataPair("num_nodes", numNodes), radioModel) } private var sleepTimeout: Job? = null @@ -1350,26 +1341,24 @@ class MeshService : Service(), Logging { val now = System.currentTimeMillis() connectTimeMsec = 0L - GeeksvilleApplication.analytics.track( - "connected_seconds", - DataPair((now - connectTimeMsec) / 1000.0) - ) + GeeksvilleApplication.analytics.track("connected_seconds", DataPair((now - connectTimeMsec) / 1000.0)) } // Have our timeout fire in the appropriate number of seconds - sleepTimeout = serviceScope.handledLaunch { - try { - // If we have a valid timeout, wait that long (+30 seconds) otherwise, just wait 30 seconds - val timeout = (localConfig.power?.lsSecs ?: 0) + 30 + sleepTimeout = + serviceScope.handledLaunch { + try { + // If we have a valid timeout, wait that long (+30 seconds) otherwise, just wait 30 seconds + val timeout = (localConfig.power?.lsSecs ?: 0) + 30 - debug("Waiting for sleeping device, timeout=$timeout secs") - delay(timeout * 1000L) - warn("Device timeout out, setting disconnected") - onConnectionChanged(ConnectionState.DISCONNECTED) - } catch (ex: CancellationException) { - debug("device sleep timeout cancelled") + debug("Waiting for sleeping device, timeout=$timeout secs") + delay(timeout * 1000L) + warn("Device timeout out, setting disconnected") + onConnectionChanged(ConnectionState.DISCONNECTED) + } catch (ex: CancellationException) { + debug("device sleep timeout cancelled") + } } - } // broadcast an intent with our new connection state serviceBroadcasts.broadcastConnection() @@ -1383,7 +1372,7 @@ class MeshService : Service(), Logging { GeeksvilleApplication.analytics.track( "mesh_disconnect", DataPair("num_nodes", numNodes), - DataPair("num_online", numOnlineNodes) + DataPair("num_online", numOnlineNodes), ) GeeksvilleApplication.analytics.track("num_nodes", DataPair(numNodes)) @@ -1397,16 +1386,15 @@ class MeshService : Service(), Logging { connectTimeMsec = System.currentTimeMillis() startConfig() } catch (ex: InvalidProtocolBufferException) { - errormsg( - "Invalid protocol buffer sent by device - update device software and try again", - ex - ) + errormsg("Invalid protocol buffer sent by device - update device software and try again", ex) } catch (ex: RadioNotConnectedException) { - // note: no need to call startDeviceSleep(), because this exception could only have reached us if it was already called - errormsg("Lost connection to radio during init - waiting for reconnect") + // note: no need to call startDeviceSleep(), because this exception could only have reached us if it was + // already called + errormsg("Lost connection to radio during init - waiting for reconnect ${ex.message}") } catch (ex: RemoteException) { // It seems that when the ESP32 goes offline it can briefly come back for a 100ms ish which - // causes the phone to try and reconnect. If we fail downloading our initial radio state we don't want to + // causes the phone to try and reconnect. If we fail downloading our initial radio state we don't want + // to // claim we have a valid connection still connectionState = ConnectionState.DEVICE_SLEEP startDeviceSleep() @@ -1436,17 +1424,11 @@ class MeshService : Service(), Logging { val currentSummary = notificationSummary val currentStats = localStats val currentStatsUpdatedAtMillis = localStatsUpdatedAtMillis - if ( - !currentSummary.isNullOrBlank() && - (previousSummary == null || !previousSummary.equals(currentSummary)) - ) { + if (!currentSummary.isNullOrBlank() && (previousSummary == null || !previousSummary.equals(currentSummary))) { previousSummary = currentSummary update = true } - if ( - currentStats != null && - (previousStats == null || !(previousStats?.equals(currentStats) ?: false)) - ) { + if (currentStats != null && (previousStats == null || !(previousStats?.equals(currentStats) ?: false))) { previousStats = currentStats update = true } @@ -1454,7 +1436,7 @@ class MeshService : Service(), Logging { serviceNotifications.updateServiceStateNotification( summaryString = currentSummary, localStats = currentStats, - currentStatsUpdatedAtMillis = currentStatsUpdatedAtMillis + currentStatsUpdatedAtMillis = currentStatsUpdatedAtMillis, ) } } @@ -1470,7 +1452,7 @@ class MeshService : Service(), Logging { connected -> ConnectionState.CONNECTED permanent -> ConnectionState.DISCONNECTED else -> ConnectionState.DEVICE_SLEEP - } + }, ) } @@ -1488,9 +1470,8 @@ class MeshService : Service(), Logging { MeshProtos.FromRadio.MODULECONFIG_FIELD_NUMBER -> handleModuleConfig(proto.moduleConfig) MeshProtos.FromRadio.QUEUESTATUS_FIELD_NUMBER -> handleQueueStatus(proto.queueStatus) MeshProtos.FromRadio.METADATA_FIELD_NUMBER -> handleMetadata(proto.metadata) - MeshProtos.FromRadio.MQTTCLIENTPROXYMESSAGE_FIELD_NUMBER -> handleMqttProxyMessage( - proto.mqttClientProxyMessage - ) + MeshProtos.FromRadio.MQTTCLIENTPROXYMESSAGE_FIELD_NUMBER -> + handleMqttProxyMessage(proto.mqttClientProxyMessage) MeshProtos.FromRadio.CLIENTNOTIFICATION_FIELD_NUMBER -> { handleClientNotification(proto.clientNotification) @@ -1514,13 +1495,14 @@ class MeshService : Service(), Logging { private fun handleDeviceConfig(config: ConfigProtos.Config) { debug("Received config ${config.toOneLineString()}") - val packetToSave = MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "Config ${config.payloadVariantCase}", - received_date = System.currentTimeMillis(), - raw_message = config.toString(), - fromRadio = fromRadio { this.config = config }, - ) + val packetToSave = + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "Config ${config.payloadVariantCase}", + received_date = System.currentTimeMillis(), + raw_message = config.toString(), + fromRadio = fromRadio { this.config = config }, + ) insertMeshLog(packetToSave) setLocalConfig(config) val configCount = localConfig.allFields.size @@ -1529,13 +1511,14 @@ class MeshService : Service(), Logging { private fun handleModuleConfig(config: ModuleConfigProtos.ModuleConfig) { debug("Received moduleConfig ${config.toOneLineString()}") - val packetToSave = MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "ModuleConfig ${config.payloadVariantCase}", - received_date = System.currentTimeMillis(), - raw_message = config.toString(), - fromRadio = fromRadio { moduleConfig = config }, - ) + val packetToSave = + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "ModuleConfig ${config.payloadVariantCase}", + received_date = System.currentTimeMillis(), + raw_message = config.toString(), + fromRadio = fromRadio { moduleConfig = config }, + ) insertMeshLog(packetToSave) setLocalModuleConfig(config) val moduleCount = moduleConfig.allFields.size @@ -1544,9 +1527,7 @@ class MeshService : Service(), Logging { private fun handleQueueStatus(queueStatus: MeshProtos.QueueStatus) { debug("queueStatus ${queueStatus.toOneLineString()}") - val (success, isFull, requestId) = with(queueStatus) { - Triple(res == 0, free == 0, meshPacketId) - } + val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, meshPacketId) } if (success && isFull) return // Queue is full, wait for free != 0 if (requestId != 0) { queueResponse.remove(requestId)?.complete(success) @@ -1557,30 +1538,30 @@ class MeshService : Service(), Logging { private fun handleChannel(ch: ChannelProtos.Channel) { debug("Received channel ${ch.index}") - val packetToSave = MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "Channel", - received_date = System.currentTimeMillis(), - raw_message = ch.toString(), - fromRadio = fromRadio { channel = ch }, - ) + val packetToSave = + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "Channel", + received_date = System.currentTimeMillis(), + raw_message = ch.toString(), + fromRadio = fromRadio { channel = ch }, + ) insertMeshLog(packetToSave) if (ch.role != ChannelProtos.Channel.Role.DISABLED) updateChannelSettings(ch) val maxChannels = myNodeInfo?.maxChannels ?: 8 radioConfigRepository.setStatusMessage("Channels (${ch.index + 1} / $maxChannels)") } - /** - * Convert a protobuf NodeInfo into our model objects and update our node DB - */ + /** Convert a protobuf NodeInfo into our model objects and update our node DB */ private fun installNodeInfo(info: MeshProtos.NodeInfo) { // Just replace/add any entry updateNodeInfo(info.num) { if (info.hasUser()) { - it.user = info.user.copy { - if (isLicensed) clearPublicKey() - if (info.viaMqtt) longName = "$longName (MQTT)" - } + it.user = + info.user.copy { + if (isLicensed) clearPublicKey() + if (info.viaMqtt) longName = "$longName (MQTT)" + } it.longName = it.user.longName it.shortName = it.user.shortName } @@ -1601,26 +1582,33 @@ class MeshService : Service(), Logging { it.viaMqtt = info.viaMqtt // hopsAway should be nullable/optional from the proto, but explicitly checking it's existence first - it.hopsAway = if (info.hasHopsAway()) { - info.hopsAway - } else { - -1 - } + it.hopsAway = + if (info.hasHopsAway()) { + info.hopsAway + } else { + -1 + } it.isFavorite = info.isFavorite it.isIgnored = info.isIgnored } } private fun handleNodeInfo(info: MeshProtos.NodeInfo) { - debug("Received nodeinfo num=${info.num}, hasUser=${info.hasUser()}, hasPosition=${info.hasPosition()}, hasDeviceMetrics=${info.hasDeviceMetrics()}") - - val packetToSave = MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "NodeInfo", - received_date = System.currentTimeMillis(), - raw_message = info.toString(), - fromRadio = fromRadio { nodeInfo = info }, + debug( + "Received nodeinfo num=${info.num}," + + " hasUser=${info.hasUser()}," + + " hasPosition=${info.hasPosition()}," + + " hasDeviceMetrics=${info.hasDeviceMetrics()}", ) + + val packetToSave = + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "NodeInfo", + received_date = System.currentTimeMillis(), + raw_message = info.toString(), + fromRadio = fromRadio { nodeInfo = info }, + ) insertMeshLog(packetToSave) newNodes.add(info) @@ -1629,33 +1617,37 @@ class MeshService : Service(), Logging { private var rawMyNodeInfo: MeshProtos.MyNodeInfo? = null - /** Regenerate the myNodeInfo model. We call this twice. Once after we receive myNodeInfo from the device - * and again after we have the node DB (which might allow us a better notion of our HwModel. + /** + * Regenerate the myNodeInfo model. We call this twice. Once after we receive myNodeInfo from the device and again + * after we have the node DB (which might allow us a better notion of our HwModel. */ private fun regenMyNodeInfo(metadata: MeshProtos.DeviceMetadata) { val myInfo = rawMyNodeInfo if (myInfo != null) { - val mi = with(myInfo) { - MyNodeEntity( - myNodeNum = myNodeNum, - model = when (val hwModel = metadata.hwModel) { - null, MeshProtos.HardwareModel.UNSET -> null - else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase() - }, - firmwareVersion = metadata.firmwareVersion, - couldUpdate = false, - shouldUpdate = false, // TODO add check after re-implementing firmware updates - currentPacketId = currentPacketId and 0xffffffffL, - messageTimeoutMsec = 5 * 60 * 1000, // constants from current firmware code - minAppVersion = minAppVersion, - maxChannels = 8, - hasWifi = metadata.hasWifi, - deviceId = deviceId.toStringUtf8(), - ) - } - serviceScope.handledLaunch { - radioConfigRepository.insertMetadata(mi.myNodeNum, metadata) - } + val mi = + with(myInfo) { + MyNodeEntity( + myNodeNum = myNodeNum, + model = + when (val hwModel = metadata.hwModel) { + null, + MeshProtos.HardwareModel.UNSET, + -> null + + else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase() + }, + firmwareVersion = metadata.firmwareVersion, + couldUpdate = false, + shouldUpdate = false, // TODO add check after re-implementing firmware updates + currentPacketId = currentPacketId and 0xffffffffL, + messageTimeoutMsec = 5 * 60 * 1000, // constants from current firmware code + minAppVersion = minAppVersion, + maxChannels = 8, + hasWifi = metadata.hasWifi, + deviceId = deviceId.toStringUtf8(), + ) + } + serviceScope.handledLaunch { radioConfigRepository.insertMetadata(mi.myNodeNum, metadata) } newMyNodeInfo = mi } } @@ -1672,17 +1664,16 @@ class MeshService : Service(), Logging { } } - /** - * Update MyNodeInfo (called from either new API version or the old one) - */ + /** Update MyNodeInfo (called from either new API version or the old one) */ private fun handleMyInfo(myInfo: MeshProtos.MyNodeInfo) { - val packetToSave = MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "MyNodeInfo", - received_date = System.currentTimeMillis(), - raw_message = myInfo.toString(), - fromRadio = fromRadio { this.myInfo = myInfo }, - ) + val packetToSave = + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "MyNodeInfo", + received_date = System.currentTimeMillis(), + raw_message = myInfo.toString(), + fromRadio = fromRadio { this.myInfo = myInfo }, + ) insertMeshLog(packetToSave) rawMyNodeInfo = myInfo @@ -1695,26 +1686,23 @@ class MeshService : Service(), Logging { } } - /** - * Update our DeviceMetadata - */ + /** Update our DeviceMetadata */ private fun handleMetadata(metadata: MeshProtos.DeviceMetadata) { debug("Received deviceMetadata ${metadata.toOneLineString()}") - val packetToSave = MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "DeviceMetadata", - received_date = System.currentTimeMillis(), - raw_message = metadata.toString(), - fromRadio = fromRadio { this.metadata = metadata }, - ) + val packetToSave = + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "DeviceMetadata", + received_date = System.currentTimeMillis(), + raw_message = metadata.toString(), + fromRadio = fromRadio { this.metadata = metadata }, + ) insertMeshLog(packetToSave) regenMyNodeInfo(metadata) } - /** - * Publish MqttClientProxyMessage (fromRadio) - */ + /** Publish MqttClientProxyMessage (fromRadio) */ private fun handleMqttProxyMessage(message: MeshProtos.MqttClientProxyMessage) { with(message) { when (payloadVariantCase) { @@ -1739,17 +1727,15 @@ class MeshService : Service(), Logging { queueResponse.remove(notification.replyId)?.complete(false) } - /** - * Connect, subscribe and receive Flow of MqttClientProxyMessage (toRadio) - */ + /** Connect, subscribe and receive Flow of MqttClientProxyMessage (toRadio) */ private fun startMqttClientProxy() { if (mqttMessageFlow?.isActive == true) return if (moduleConfig.mqtt.enabled && moduleConfig.mqtt.proxyToClientEnabled) { - mqttMessageFlow = mqttRepository.proxyMessageFlow.onEach { message -> - sendToRadio(ToRadio.newBuilder().apply { mqttClientProxyMessage = message }) - }.catch { throwable -> - radioConfigRepository.setErrorMessage("MqttClientProxy failed: $throwable") - }.launchIn(serviceScope) + mqttMessageFlow = + mqttRepository.proxyMessageFlow + .onEach { message -> sendToRadio(ToRadio.newBuilder().apply { mqttClientProxyMessage = message }) } + .catch { throwable -> radioConfigRepository.setErrorMessage("MqttClientProxy failed: $throwable") } + .launchIn(serviceScope) } } @@ -1761,9 +1747,9 @@ class MeshService : Service(), Logging { } } - // If we've received our initial config, our radio settings and all of our channels, send any queued packets and broadcast connected to clients + // If we've received our initial config, our radio settings and all of our channels, send any queued packets and + // broadcast connected to clients private fun onHasSettings() { - processQueuedPackets() // send any packets that were queued up startMqttClientProxy() @@ -1775,14 +1761,14 @@ class MeshService : Service(), Logging { private fun handleConfigComplete(configCompleteId: Int) { if (configCompleteId == configNonce) { - - val packetToSave = MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "ConfigComplete", - received_date = System.currentTimeMillis(), - raw_message = configCompleteId.toString(), - fromRadio = fromRadio { this.configCompleteId = configCompleteId }, - ) + val packetToSave = + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "ConfigComplete", + received_date = System.currentTimeMillis(), + raw_message = configCompleteId.toString(), + fromRadio = fromRadio { this.configCompleteId = configCompleteId }, + ) insertMeshLog(packetToSave) // This was our config request @@ -1797,24 +1783,17 @@ class MeshService : Service(), Logging { newNodes.clear() // Just to save RAM ;-) serviceScope.handledLaunch { - radioConfigRepository.installNodeDB( - myNodeInfo!!, - nodeDBbyNodeNum.values.toList() - ) + radioConfigRepository.installNodeDB(myNodeInfo!!, nodeDBbyNodeNum.values.toList()) } haveNodeDB = true // we now have nodes from real hardware - sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { - setTimeOnly = currentSecond() - }) + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { setTimeOnly = currentSecond() }) sendAnalytics() if (deviceVersion < minDeviceVersion || appVersion < minAppVersion) { info("Device firmware or app is too old, faking config so firmware update can occur") - setLocalConfig(config { - security = localConfig.security.copy { isManaged = true } - }) + setLocalConfig(config { security = localConfig.security.copy { isManaged = true } }) } onHasSettings() } @@ -1823,9 +1802,7 @@ class MeshService : Service(), Logging { } } - /** - * Start the modern (REV2) API configuration flow - */ + /** Start the modern (REV2) API configuration flow */ private fun startConfig() { configNonce += 1 newNodes.clear() @@ -1833,19 +1810,11 @@ class MeshService : Service(), Logging { debug("Starting config nonce=$configNonce") - sendToRadio(ToRadio.newBuilder().apply { - this.wantConfigId = configNonce - }) + sendToRadio(ToRadio.newBuilder().apply { this.wantConfigId = configNonce }) } - /** - * Send a position (typically from our built in GPS) into the mesh. - */ - private fun sendPosition( - position: MeshProtos.Position, - destNum: Int? = null, - wantResponse: Boolean = false - ) { + /** Send a position (typically from our built in GPS) into the mesh. */ + private fun sendPosition(position: MeshProtos.Position, destNum: Int? = null, wantResponse: Boolean = false) { try { val mi = myNodeInfo if (mi != null) { @@ -1859,64 +1828,55 @@ class MeshService : Service(), Logging { sendToRadio( newMeshPacketTo(idNum).buildMeshPacket( - channel = if (destNum == null) { + channel = + if (destNum == null) { 0 } else { - nodeDBbyNodeNum[destNum]?.channel - ?: 0 + nodeDBbyNodeNum[destNum]?.channel ?: 0 }, priority = MeshPacket.Priority.BACKGROUND, ) { portnumValue = Portnums.PortNum.POSITION_APP_VALUE payload = position.toByteString() this.wantResponse = wantResponse - }) + }, + ) } } catch (ex: BLEException) { warn("Ignoring disconnected radio during gps location update") } } - /** - * Send setOwner admin packet with [MeshProtos.User] protobuf - */ + /** Send setOwner admin packet with [MeshProtos.User] protobuf */ private fun setOwner(packetId: Int, user: MeshProtos.User) = with(user) { - val dest = nodeDBbyID[id] - ?: throw Exception("Can't set user without a NodeInfo") // this shouldn't happen + val dest = nodeDBbyID[id] ?: throw Exception("Can't set user without a NodeInfo") // this shouldn't happen val old = dest.user @Suppress("ComplexCondition") - if ( - user == old - ) { + if (user == old) { debug("Ignoring nop owner change") } else { debug( "setOwner Id: $id longName: ${longName.anonymize}" + - " shortName: $shortName isLicensed: $isLicensed" + - " isUnmessagable: $isUnmessagable" + " shortName: $shortName isLicensed: $isLicensed" + + " isUnmessagable: $isUnmessagable", ) // Also update our own map for our nodeNum, by handling the packet just like packets from other users handleReceivedUser(dest.num, user) // encapsulate our payload in the proper protobuf and fire it off - sendToRadio(newMeshPacketTo(dest.num).buildAdminPacket(id = packetId) { - setOwner = user - }) + sendToRadio(newMeshPacketTo(dest.num).buildAdminPacket(id = packetId) { setOwner = user }) } } // Do not use directly, instead call generatePacketId() private var currentPacketId = Random(System.currentTimeMillis()).nextLong().absoluteValue - /** - * Generate a unique packet ID (if we know enough to do so - otherwise return 0 so the device will do it) - */ + /** Generate a unique packet ID (if we know enough to do so - otherwise return 0 so the device will do it) */ @Synchronized private fun generatePacketId(): Int { - val numPacketIds = - ((1L shl 32) - 1) // A mask for only the valid packet ID bits, either 255 or maxint + val numPacketIds = ((1L shl 32) - 1) // A mask for only the valid packet ID bits, either 255 or maxint currentPacketId++ @@ -1945,51 +1905,42 @@ class MeshService : Service(), Logging { } private fun importContact(contact: AdminProtos.SharedContact) { - sendToRadio( - newMeshPacketTo(myNodeNum).buildAdminPacket { - addContact = contact - } - ) - handleReceivedUser( - contact.nodeNum, - contact.user - ) + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { addContact = contact }) + handleReceivedUser(contact.nodeNum, contact.user) } private fun getDeviceMetadata(destNum: Int) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(wantResponse = true) { - getDeviceMetadataRequest = true - }) + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(wantResponse = true) { getDeviceMetadataRequest = true }) } private fun favoriteNode(node: Node) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { - if (node.isFavorite) { - debug("removing node ${node.num} from favorite list") - removeFavoriteNode = node.num - } else { - debug("adding node ${node.num} to favorite list") - setFavoriteNode = node.num - } - }) - updateNodeInfo(node.num) { - it.isFavorite = !node.isFavorite - } + sendToRadio( + newMeshPacketTo(myNodeNum).buildAdminPacket { + if (node.isFavorite) { + debug("removing node ${node.num} from favorite list") + removeFavoriteNode = node.num + } else { + debug("adding node ${node.num} to favorite list") + setFavoriteNode = node.num + } + }, + ) + updateNodeInfo(node.num) { it.isFavorite = !node.isFavorite } } private fun ignoreNode(node: Node) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { - if (node.isIgnored) { - debug("removing node ${node.num} from ignore list") - removeIgnoredNode = node.num - } else { - debug("adding node ${node.num} to ignore list") - setIgnoredNode = node.num - } - }) - updateNodeInfo(node.num) { - it.isIgnored = !node.isIgnored - } + sendToRadio( + newMeshPacketTo(myNodeNum).buildAdminPacket { + if (node.isIgnored) { + debug("removing node ${node.num} from ignore list") + removeIgnoredNode = node.num + } else { + debug("adding node ${node.num} to ignore list") + setIgnoredNode = node.num + } + }, + ) + updateNodeInfo(node.num) { it.isIgnored = !node.isIgnored } } private fun sendReaction(reaction: ServiceAction.Reaction) = toRemoteExceptions { @@ -1997,15 +1948,13 @@ class MeshService : Service(), Logging { val channel = reaction.contactKey[0].digitToInt() val destNum = reaction.contactKey.substring(1) - val packet = newMeshPacketTo(destNum).buildMeshPacket( - channel = channel, - priority = MeshPacket.Priority.BACKGROUND, - ) { - emoji = 1 - replyId = reaction.replyId - portnumValue = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE - payload = ByteString.copyFrom(reaction.emoji.encodeToByteArray()) - } + val packet = + newMeshPacketTo(destNum).buildMeshPacket(channel = channel, priority = MeshPacket.Priority.BACKGROUND) { + emoji = 1 + replyId = reaction.replyId + portnumValue = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE + payload = ByteString.copyFrom(reaction.emoji.encodeToByteArray()) + } sendToRadio(packet) rememberReaction(packet.copy { from = myNodeNum }) } @@ -2024,24 +1973,24 @@ class MeshService : Service(), Logging { private fun updateLastAddress(deviceAddr: String?) { debug("setDeviceAddress: Passing through device change to radio service: ${deviceAddr.anonymize}") when (deviceAddr) { - null, "" -> { + null, + "", + -> { debug("SetDeviceAddress: No previous device address, setting new one") _lastAddress.value = deviceAddr - sharedPreferences.edit { - putString("device_address", deviceAddr) - } + sharedPreferences.edit { putString("device_address", deviceAddr) } } - lastAddress.value, NO_DEVICE_SELECTED -> { + lastAddress.value, + NO_DEVICE_SELECTED, + -> { debug("SetDeviceAddress: Device address is the none or same, ignoring") } else -> { debug("SetDeviceAddress: Device address changed from $lastAddress to $deviceAddr") _lastAddress.value = deviceAddr - sharedPreferences.edit { - putString("device_address", deviceAddr) - } + sharedPreferences.edit { putString("device_address", deviceAddr) } clearDatabases() clearNotifications() } @@ -2051,324 +2000,320 @@ class MeshService : Service(), Logging { private fun clearNotifications() { serviceNotifications.clearNotifications() } - private val binder = object : IMeshService.Stub() { - override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions { - debug("Passing through device change to radio service: ${deviceAddr.anonymize}") - updateLastAddress(deviceAddr) - val res = radioInterfaceService.setDeviceAddress(deviceAddr) - if (res) { - discardNodeDB() - } else { - serviceBroadcasts.broadcastConnection() + private val binder = + object : IMeshService.Stub() { + + override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions { + debug("Passing through device change to radio service: ${deviceAddr.anonymize}") + updateLastAddress(deviceAddr) + val res = radioInterfaceService.setDeviceAddress(deviceAddr) + if (res) { + discardNodeDB() + } else { + serviceBroadcasts.broadcastConnection() + } + res } - res - } - // Note: bound methods don't get properly exception caught/logged, so do that with a wrapper - // per https://blog.classycode.com/dealing-with-exceptions-in-aidl-9ba904c6d63 - override fun subscribeReceiver(packageName: String, receiverName: String) = - toRemoteExceptions { + // Note: bound methods don't get properly exception caught/logged, so do that with a wrapper + // per https://blog.classycode.com/dealing-with-exceptions-in-aidl-9ba904c6d63 + override fun subscribeReceiver(packageName: String, receiverName: String) = toRemoteExceptions { clientPackages[receiverName] = packageName } - override fun getUpdateStatus(): Int = -4 // ProgressNotStarted + override fun getUpdateStatus(): Int = -4 // ProgressNotStarted - override fun startFirmwareUpdate() = toRemoteExceptions { - // TODO reimplement this after we have a new firmware update mechanism - } + override fun startFirmwareUpdate() = toRemoteExceptions { + // TODO reimplement this after we have a new firmware update mechanism + } - override fun getMyNodeInfo(): MyNodeInfo? = this@MeshService.myNodeInfo?.toMyNodeInfo() + override fun getMyNodeInfo(): MyNodeInfo? = this@MeshService.myNodeInfo?.toMyNodeInfo() - override fun getMyId() = toRemoteExceptions { myNodeID } + override fun getMyId() = toRemoteExceptions { myNodeID } - override fun getPacketId() = toRemoteExceptions { generatePacketId() } + override fun getPacketId() = toRemoteExceptions { generatePacketId() } - override fun setOwner(user: MeshUser) = toRemoteExceptions { - setOwner(generatePacketId(), user { - id = user.id - longName = user.longName - shortName = user.shortName - isLicensed = user.isLicensed - }) - } - - override fun setRemoteOwner(id: Int, payload: ByteArray) = toRemoteExceptions { - val parsed = MeshProtos.User.parseFrom(payload) - setOwner(id, parsed) - } - - override fun getRemoteOwner(id: Int, destNum: Int) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - getOwnerRequest = true - }) - } - - override fun send(p: DataPacket) { - toRemoteExceptions { - if (p.id == 0) p.id = generatePacketId() - - info("sendData dest=${p.to}, id=${p.id} <- ${p.bytes!!.size} bytes (connectionState=$connectionState)") - - if (p.dataType == 0) { - throw Exception("Port numbers must be non-zero!") // we are now more strict - } - - if (p.bytes.size >= MeshProtos.Constants.DATA_PAYLOAD_LEN.number) { - p.status = MessageStatus.ERROR - throw RemoteException("Message too long") - } else { - p.status = MessageStatus.QUEUED - } - - if (connectionState == ConnectionState.CONNECTED) try { - sendNow(p) - } catch (ex: Exception) { - errormsg("Error sending message, so enqueueing", ex) - enqueueForSending(p) - } else { - enqueueForSending(p) - } - serviceBroadcasts.broadcastMessageStatus(p) - - // Keep a record of DataPackets, so GUIs can show proper chat history - rememberDataPacket(p, false) - - GeeksvilleApplication.analytics.track( - "data_send", - DataPair("num_bytes", p.bytes.size), - DataPair("type", p.dataType) - ) - - GeeksvilleApplication.analytics.track( - "num_data_sent", - DataPair(1) + override fun setOwner(user: MeshUser) = toRemoteExceptions { + setOwner( + generatePacketId(), + user { + id = user.id + longName = user.longName + shortName = user.shortName + isLicensed = user.isLicensed + }, ) } - } - override fun getConfig(): ByteArray = toRemoteExceptions { - this@MeshService.localConfig.toByteArray() ?: throw NoDeviceConfigException() - } + override fun setRemoteOwner(id: Int, payload: ByteArray) = toRemoteExceptions { + val parsed = MeshProtos.User.parseFrom(payload) + setOwner(id, parsed) + } - /** Send our current radio config to the device - */ - override fun setConfig(payload: ByteArray) = toRemoteExceptions { - setRemoteConfig(generatePacketId(), myNodeNum, payload) - } + override fun getRemoteOwner(id: Int, destNum: Int) = toRemoteExceptions { + sendToRadio( + newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { getOwnerRequest = true }, + ) + } - override fun setRemoteConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions { - debug("Setting new radio config!") - val config = ConfigProtos.Config.parseFrom(payload) - sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setConfig = config }) - if (num == myNodeNum) setLocalConfig(config) // Update our local copy - } + override fun send(p: DataPacket) { + toRemoteExceptions { + if (p.id == 0) p.id = generatePacketId() - override fun getRemoteConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - if (config == AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE) { - getDeviceMetadataRequest = true - } else { - getConfigRequestValue = config + info( + "sendData dest=${p.to}, id=${p.id} <- ${p.bytes!!.size} bytes" + + " (connectionState=$connectionState)", + ) + + if (p.dataType == 0) { + throw Exception("Port numbers must be non-zero!") // we are now more strict + } + + if (p.bytes.size >= MeshProtos.Constants.DATA_PAYLOAD_LEN.number) { + p.status = MessageStatus.ERROR + throw RemoteException("Message too long") + } else { + p.status = MessageStatus.QUEUED + } + + if (connectionState == ConnectionState.CONNECTED) { + try { + sendNow(p) + } catch (ex: Exception) { + errormsg("Error sending message, so enqueueing", ex) + enqueueForSending(p) + } + } else { + enqueueForSending(p) + } + serviceBroadcasts.broadcastMessageStatus(p) + + // Keep a record of DataPackets, so GUIs can show proper chat history + rememberDataPacket(p, false) + + GeeksvilleApplication.analytics.track( + "data_send", + DataPair("num_bytes", p.bytes.size), + DataPair("type", p.dataType), + ) + + GeeksvilleApplication.analytics.track("num_data_sent", DataPair(1)) } - }) - } + } - /** Send our current module config to the device - */ - override fun setModuleConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions { - debug("Setting new module config!") - val config = ModuleConfigProtos.ModuleConfig.parseFrom(payload) - sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setModuleConfig = config }) - if (num == myNodeNum) setLocalModuleConfig(config) // Update our local copy - } + override fun getConfig(): ByteArray = toRemoteExceptions { + this@MeshService.localConfig.toByteArray() ?: throw NoDeviceConfigException() + } - override fun getModuleConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - getModuleConfigRequestValue = config - }) - } + /** Send our current radio config to the device */ + override fun setConfig(payload: ByteArray) = toRemoteExceptions { + setRemoteConfig(generatePacketId(), myNodeNum, payload) + } - override fun setRingtone(destNum: Int, ringtone: String) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { - setRingtoneMessage = ringtone - }) - } + override fun setRemoteConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions { + debug("Setting new radio config!") + val config = ConfigProtos.Config.parseFrom(payload) + sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setConfig = config }) + if (num == myNodeNum) setLocalConfig(config) // Update our local copy + } - override fun getRingtone(id: Int, destNum: Int) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - getRingtoneRequest = true - }) - } + override fun getRemoteConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { + sendToRadio( + newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { + if (config == AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE) { + getDeviceMetadataRequest = true + } else { + getConfigRequestValue = config + } + }, + ) + } - override fun setCannedMessages(destNum: Int, messages: String) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { - setCannedMessageModuleMessages = messages - }) - } + /** Send our current module config to the device */ + override fun setModuleConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions { + debug("Setting new module config!") + val config = ModuleConfigProtos.ModuleConfig.parseFrom(payload) + sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setModuleConfig = config }) + if (num == myNodeNum) setLocalModuleConfig(config) // Update our local copy + } - override fun getCannedMessages(id: Int, destNum: Int) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - getCannedMessageModuleMessagesRequest = true - }) - } + override fun getModuleConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { + sendToRadio( + newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { + getModuleConfigRequestValue = config + }, + ) + } - override fun setChannel(payload: ByteArray?) = toRemoteExceptions { - setRemoteChannel(generatePacketId(), myNodeNum, payload) - } + override fun setRingtone(destNum: Int, ringtone: String) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { setRingtoneMessage = ringtone }) + } - override fun setRemoteChannel(id: Int, num: Int, payload: ByteArray?) = toRemoteExceptions { - val channel = ChannelProtos.Channel.parseFrom(payload) - sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setChannel = channel }) - } + override fun getRingtone(id: Int, destNum: Int) = toRemoteExceptions { + sendToRadio( + newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { + getRingtoneRequest = true + }, + ) + } - override fun getRemoteChannel(id: Int, destNum: Int, index: Int) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - getChannelRequest = index + 1 - }) - } + override fun setCannedMessages(destNum: Int, messages: String) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { setCannedMessageModuleMessages = messages }) + } - override fun beginEditSettings() = toRemoteExceptions { - sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { - beginEditSettings = true - }) - } + override fun getCannedMessages(id: Int, destNum: Int) = toRemoteExceptions { + sendToRadio( + newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { + getCannedMessageModuleMessagesRequest = true + }, + ) + } - override fun commitEditSettings() = toRemoteExceptions { - sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { - commitEditSettings = true - }) - } + override fun setChannel(payload: ByteArray?) = toRemoteExceptions { + setRemoteChannel(generatePacketId(), myNodeNum, payload) + } - override fun getChannelSet(): ByteArray = toRemoteExceptions { - this@MeshService.channelSet.toByteArray() - } + override fun setRemoteChannel(id: Int, num: Int, payload: ByteArray?) = toRemoteExceptions { + val channel = ChannelProtos.Channel.parseFrom(payload) + sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setChannel = channel }) + } - override fun getNodes(): MutableList = toRemoteExceptions { - val r = nodeDBbyNodeNum.values.map { it.toNodeInfo() }.toMutableList() - info("in getOnline, count=${r.size}") - // return arrayOf("+16508675309") - r - } + override fun getRemoteChannel(id: Int, destNum: Int, index: Int) = toRemoteExceptions { + sendToRadio( + newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { + getChannelRequest = index + 1 + }, + ) + } - override fun connectionState(): String = toRemoteExceptions { - val r = this@MeshService.connectionState - info("in connectionState=$r") - r.toString() - } + override fun beginEditSettings() = toRemoteExceptions { + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { beginEditSettings = true }) + } - override fun startProvideLocation() = toRemoteExceptions { - startLocationRequests() - } + override fun commitEditSettings() = toRemoteExceptions { + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { commitEditSettings = true }) + } - override fun stopProvideLocation() = toRemoteExceptions { - stopLocationRequests() - } + override fun getChannelSet(): ByteArray = toRemoteExceptions { this@MeshService.channelSet.toByteArray() } - override fun removeByNodenum(requestId: Int, nodeNum: Int) = toRemoteExceptions { - nodeDBbyNodeNum.remove(nodeNum) - sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { - removeByNodenum = nodeNum - }) - } - override fun requestUserInfo(destNum: Int) = toRemoteExceptions { - if (destNum != myNodeNum) { - sendToRadio(newMeshPacketTo(destNum - ).buildMeshPacket( - channel = nodeDBbyNodeNum[destNum]?.channel ?: 0 - ) { - portnumValue = Portnums.PortNum.NODEINFO_APP_VALUE - wantResponse = true - payload = nodeDBbyNodeNum[myNodeNum]!!.user.toByteString() - }) + override fun getNodes(): MutableList = toRemoteExceptions { + val r = nodeDBbyNodeNum.values.map { it.toNodeInfo() }.toMutableList() + info("in getOnline, count=${r.size}") + // return arrayOf("+16508675309") + r + } + + override fun connectionState(): String = toRemoteExceptions { + val r = this@MeshService.connectionState + info("in connectionState=$r") + r.toString() + } + + override fun startProvideLocation() = toRemoteExceptions { startLocationRequests() } + + override fun stopProvideLocation() = toRemoteExceptions { stopLocationRequests() } + + override fun removeByNodenum(requestId: Int, nodeNum: Int) = toRemoteExceptions { + nodeDBbyNodeNum.remove(nodeNum) + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { removeByNodenum = nodeNum }) + } + + override fun requestUserInfo(destNum: Int) = toRemoteExceptions { + if (destNum != myNodeNum) { + sendToRadio( + newMeshPacketTo(destNum).buildMeshPacket(channel = nodeDBbyNodeNum[destNum]?.channel ?: 0) { + portnumValue = Portnums.PortNum.NODEINFO_APP_VALUE + wantResponse = true + payload = nodeDBbyNodeNum[myNodeNum]!!.user.toByteString() + }, + ) + } + } + + override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions { + if (destNum != myNodeNum) { + // Determine the best position to send based on user preferences and available data + val provideLocation = sharedPreferences.getBoolean("provide-location-$myNodeNum", false) + val currentPosition = + when { + // Use provided position if valid and user allows phone location sharing + provideLocation && position.isValid() -> position + // Otherwise use the last valid position from nodeDB (node GPS or static) + else -> nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() } + } + + if (currentPosition == null) { + debug("Position request skipped - no valid position available") + return@toRemoteExceptions + } + + // Convert Position to MeshProtos.Position for the payload + val meshPosition = position { + latitudeI = Position.degI(currentPosition.latitude) + longitudeI = Position.degI(currentPosition.longitude) + altitude = currentPosition.altitude + time = currentSecond() + } + + sendToRadio( + newMeshPacketTo(destNum).buildMeshPacket( + channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, + priority = MeshPacket.Priority.BACKGROUND, + ) { + portnumValue = Portnums.PortNum.POSITION_APP_VALUE + payload = meshPosition.toByteString() + wantResponse = true + }, + ) + } + } + + override fun setFixedPosition(destNum: Int, position: Position) = toRemoteExceptions { + val pos = position { + latitudeI = Position.degI(position.latitude) + longitudeI = Position.degI(position.longitude) + altitude = position.altitude + } + sendToRadio( + newMeshPacketTo(destNum).buildAdminPacket { + if (position != Position(0.0, 0.0, 0)) { + setFixedPosition = pos + } else { + removeFixedPosition = true + } + }, + ) + updateNodeInfo(destNum) { it.setPosition(pos, currentSecond()) } + } + + override fun requestTraceroute(requestId: Int, destNum: Int) = toRemoteExceptions { + sendToRadio( + newMeshPacketTo(destNum).buildMeshPacket( + wantAck = true, + id = requestId, + channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, + ) { + portnumValue = Portnums.PortNum.TRACEROUTE_APP_VALUE + wantResponse = true + }, + ) + } + + override fun requestShutdown(requestId: Int, destNum: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { shutdownSeconds = 5 }) + } + + override fun requestReboot(requestId: Int, destNum: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { rebootSeconds = 5 }) + } + + override fun requestFactoryReset(requestId: Int, destNum: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { factoryResetDevice = 1 }) + } + + override fun requestNodedbReset(requestId: Int, destNum: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { nodedbReset = 1 }) } } - override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions { - if (destNum != myNodeNum) { - // Determine the best position to send based on user preferences and available data - val provideLocation = sharedPreferences.getBoolean("provide-location-$myNodeNum", false) - val currentPosition = when { - // Use provided position if valid and user allows phone location sharing - provideLocation && position.isValid() -> position - // Otherwise use the last valid position from nodeDB (node GPS or static) - else -> nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() } - } - - if (currentPosition == null) { - debug("Position request skipped - no valid position available") - return@toRemoteExceptions - } - - // Convert Position to MeshProtos.Position for the payload - val meshPosition = position { - latitudeI = Position.degI(currentPosition.latitude) - longitudeI = Position.degI(currentPosition.longitude) - altitude = currentPosition.altitude - time = currentSecond() - } - - sendToRadio(newMeshPacketTo(destNum).buildMeshPacket( - channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, - priority = MeshPacket.Priority.BACKGROUND, - ) { - portnumValue = Portnums.PortNum.POSITION_APP_VALUE - payload = meshPosition.toByteString() - wantResponse = true - }) - } - } - - override fun setFixedPosition(destNum: Int, position: Position) = toRemoteExceptions { - val pos = position { - latitudeI = Position.degI(position.latitude) - longitudeI = Position.degI(position.longitude) - altitude = position.altitude - } - sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { - if (position != Position(0.0, 0.0, 0)) { - setFixedPosition = pos - } else { - removeFixedPosition = true - } - }) - updateNodeInfo(destNum) { - it.setPosition(pos, currentSecond()) - } - } - - override fun requestTraceroute(requestId: Int, destNum: Int) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(destNum).buildMeshPacket( - wantAck = true, - id = requestId, - channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, - ) { - portnumValue = Portnums.PortNum.TRACEROUTE_APP_VALUE - wantResponse = true - }) - } - - override fun requestShutdown(requestId: Int, destNum: Int) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { - shutdownSeconds = 5 - }) - } - - override fun requestReboot(requestId: Int, destNum: Int) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { - rebootSeconds = 5 - }) - } - - override fun requestFactoryReset(requestId: Int, destNum: Int) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { - factoryResetDevice = 1 - }) - } - - override fun requestNodedbReset(requestId: Int, destNum: Int) = toRemoteExceptions { - sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { - nodedbReset = 1 - }) - } - } } 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 3f3c740e6..b47f0caa2 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt @@ -38,7 +38,6 @@ import com.geeksville.mesh.MainActivity import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.R import com.geeksville.mesh.TelemetryProtos.LocalStats -import com.geeksville.mesh.android.notificationManager import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.navigation.DEEP_LINK_BASE_URI import com.geeksville.mesh.service.ReplyReceiver.Companion.KEY_TEXT_REPLY @@ -54,8 +53,8 @@ class MeshServiceNotifications(private val context: Context) { const val MAX_BATTERY_LEVEL = 100 } - private val notificationManager: NotificationManager - get() = context.notificationManager + private val notificationManager: NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager // We have two notification channels: one for general service status and another one for messages val notifyId = 101 diff --git a/app/src/main/java/com/geeksville/mesh/service/SafeBluetooth.kt b/app/src/main/java/com/geeksville/mesh/service/SafeBluetooth.kt index 64d2e9d3f..b2d559586 100644 --- a/app/src/main/java/com/geeksville/mesh/service/SafeBluetooth.kt +++ b/app/src/main/java/com/geeksville/mesh/service/SafeBluetooth.kt @@ -17,97 +17,102 @@ package com.geeksville.mesh.service -import android.bluetooth.* +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile import android.content.Context +import android.content.pm.PackageManager import android.os.Build import android.os.DeadObjectException import android.os.Handler import android.os.Looper +import androidx.annotation.RequiresPermission +import androidx.core.content.ContextCompat import com.geeksville.mesh.android.GeeksvilleApplication import com.geeksville.mesh.android.Logging import com.geeksville.mesh.concurrent.CallbackContinuation import com.geeksville.mesh.concurrent.Continuation import com.geeksville.mesh.concurrent.SyncContinuation -import com.geeksville.mesh.android.bluetoothManager import com.geeksville.mesh.util.exceptionReporter -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.io.Closeable -import java.util.* +import java.util.Random +import java.util.UUID - -/// Return a standard BLE 128 bit UUID from the short 16 bit versions +// / Return a standard BLE 128 bit UUID from the short 16 bit versions fun longBLEUUID(hexFour: String): UUID = UUID.fromString("0000$hexFour-0000-1000-8000-00805f9b34fb") - /** * Uses coroutines to safely access a bluetooth GATT device with a synchronous API * - * The BTLE API on android is dumb. You can only have one outstanding operation in flight to - * the device. If you try to do something when something is pending, the operation just returns - * false. You are expected to chain your operations from the results callbacks. + * The BTLE API on android is dumb. You can only have one outstanding operation in flight to the device. If you try to + * do something when something is pending, the operation just returns false. You are expected to chain your operations + * from the results callbacks. * * This class fixes the API by using coroutines to let you safely do a series of BTLE operations. */ class SafeBluetooth(private val context: Context, private val device: BluetoothDevice) : - Logging, Closeable { + Logging, + Closeable { - /// Timeout before we declare a bluetooth operation failed (used for synchronous API operations only) + // / Timeout before we declare a bluetooth operation failed (used for synchronous API operations only) var timeoutMsec = 20 * 1000L - /// Users can access the GATT directly as needed - @Volatile - var gatt: BluetoothGatt? = null + // / Users can access the GATT directly as needed + @Volatile var gatt: BluetoothGatt? = null - @Volatile - var state = BluetoothProfile.STATE_DISCONNECTED + @Volatile var state = BluetoothProfile.STATE_DISCONNECTED - @Volatile - private var currentWork: BluetoothContinuation? = null + @Volatile private var currentWork: BluetoothContinuation? = null private val workQueue = mutableListOf() // Called for reconnection attemps - @Volatile - private var connectionCallback: ((Result) -> Unit)? = null + @Volatile private var connectionCallback: ((Result) -> Unit)? = null - @Volatile - private var lostConnectCallback: (() -> Unit)? = null + @Volatile private var lostConnectCallback: (() -> Unit)? = null - /// from characteristic UUIDs to the handler function for notfies + // / from characteristic UUIDs to the handler function for notfies private val notifyHandlers = mutableMapOf Unit>() private val serviceScope = CoroutineScope(Dispatchers.IO) - /** - * A BLE status code based error - */ + /** A BLE status code based error */ class BLEStatusException(val status: Int, msg: String) : BLEException(msg) // 0x2902 org.bluetooth.descriptor.gatt.client_characteristic_configuration.xml - private val configurationDescriptorUUID = - longBLEUUID("2902") + private val configurationDescriptorUUID = longBLEUUID("2902") /** - * a schedulable bit of bluetooth work, includes both the closure to call to start the operation - * and the completion (either async or sync) to call when it completes + * a schedulable bit of bluetooth work, includes both the closure to call to start the operation and the completion + * (either async or sync) to call when it completes */ private class BluetoothContinuation( val tag: String, val completion: com.geeksville.mesh.concurrent.Continuation<*>, val timeoutMillis: Long = 0, // If we want to timeout this operation at a certain time, use a non zero value - private val startWorkFn: () -> Boolean + private val startWorkFn: () -> Boolean, ) : Logging { - /// Start running a queued bit of work, return true for success or false for fatal bluetooth error + // / Start running a queued bit of work, return true for success or false for fatal bluetooth error fun startWork(): Boolean { debug("Starting work: $tag") return startWorkFn() } - override fun toString(): String { - return "Work:$tag" - } + override fun toString(): String = "Work:$tag" - /// Connection work items are treated specially + // / Connection work items are treated specially fun isConnect() = tag == "connect" || tag == "reconnect" } @@ -117,213 +122,258 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD */ private val mHandler: Handler = Handler(Looper.getMainLooper()) + /** + * Attempts an emergency restart of the Bluetooth adapter. This is a workaround for certain BLE stack issues. It + * checks for necessary permissions (BLUETOOTH_CONNECT on API 31+, BLUETOOTH_ADMIN on older versions) before + * attempting to disable and then re-enable the adapter. + */ + @Suppress("ReturnCount") fun restartBle() { GeeksvilleApplication.analytics.track("ble_restart") // record # of times we needed to use this nasty hack errormsg("Doing emergency BLE restart") - context.bluetoothManager?.adapter?.let { adp -> - if (adp.isEnabled) { - adp.disable() - // TODO: display some kind of UI about restarting BLE - mHandler.postDelayed(object : Runnable { + + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager + val adapter = bluetoothManager?.adapter + + if (adapter == null) { + errormsg("BluetoothAdapter not available for BLE restart.") + return + } + + val hasPermission = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == + PackageManager.PERMISSION_GRANTED + } else { + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADMIN) == + PackageManager.PERMISSION_GRANTED + } + + if (!hasPermission) { + errormsg("Missing Bluetooth permission (CONNECT or ADMIN) for BLE restart.") + return + } + + if (adapter.isEnabled) { + warn("Attempting to disable Bluetooth adapter.") + if (!adapter.disable()) { + errormsg("adapter.disable() failed.") + return + } + // TODO: display some kind of UI about restarting BLE + mHandler.postDelayed( + object : Runnable { + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) override fun run() { - if (!adp.isEnabled) { - adp.enable() + if (!adapter.isEnabled) { + warn("Attempting to re-enable Bluetooth adapter.") + if (!adapter.enable()) { + errormsg("adapter.enable() failed.") + } else { + info("Bluetooth adapter re-enabled.") + } } else { + // Adapter might have been re-enabled by user or another process, or disable() is async and + // hasn't completed. + // Or, isEnabled check post-disable was too quick. + // If it's still enabled, we retry enabling check later, assuming disable will eventually + // take effect. + warn("Adapter still enabled, retrying enable check soon.") mHandler.postDelayed(this, 2500) } } - }, 2500) + }, + 2500, + ) + } else { + info("Bluetooth adapter already disabled, attempting to enable.") + if (!adapter.enable()) { + errormsg("adapter.enable() failed while adapter was already disabled.") + } else { + info("Bluetooth adapter enabled.") } } } + companion object { + private const val STATUS_RELIABLE_WRITE_FAILED = 4403 + private const val STATUS_TIMEOUT = 4404 + private const val STATUS_NOSTART = 4405 + private const val STATUS_SIMFAILURE = 4406 + } + // Our own custom BLE status codes - private val STATUS_RELIABLE_WRITE_FAILED = 4403 - private val STATUS_TIMEOUT = 4404 - private val STATUS_NOSTART = 4405 - private val STATUS_SIMFAILURE = 4406 /** * Should we automatically try to reconnect when we lose our connection? * - * Originally this was true, but over time (now that clients are smarter and need to build - * up more state) I see this was a mistake. Now if the connection drops we just call - * the lostConnection callback and the client of this API is responsible for reconnecting. - * This also prevents nasty races when sometimes both the upperlayer and this layer decide to reconnect - * simultaneously. + * Originally this was true, but over time (now that clients are smarter and need to build up more state) I see this + * was a mistake. Now if the connection drops we just call the lostConnection callback and the client of this API is + * responsible for reconnecting. This also prevents nasty races when sometimes both the upperlayer and this layer + * decide to reconnect simultaneously. */ + @Suppress("UnusedPrivateProperty") private val autoReconnect = false - private val gattCallback = object : BluetoothGattCallback() { + private val gattCallback = + object : BluetoothGattCallback() { - override fun onConnectionStateChange( - g: BluetoothGatt, - status: Int, - newState: Int - ) = exceptionReporter { - info("new bluetooth connection state $newState, status $status") + @SuppressLint("MissingPermission") + override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) = exceptionReporter { + info("new bluetooth connection state $newState, status $status") - when (newState) { - BluetoothProfile.STATE_CONNECTED -> { - state = - newState // we only care about connected/disconnected - not the transitional states + when (newState) { + BluetoothProfile.STATE_CONNECTED -> { + state = newState // we only care about connected/disconnected - not the transitional states - // If autoconnect is on and this connect attempt failed, hopefully some future attempt will succeed - if (status != BluetoothGatt.GATT_SUCCESS && autoConnect) { - errormsg("Connect attempt failed $status, not calling connect completion handler...") - } else - completeWork(status, Unit) - } - BluetoothProfile.STATE_DISCONNECTED -> { - if (gatt == null) { - errormsg("No gatt: ignoring connection state $newState, status $status") - } else if (isClosing) { - info("Got disconnect because we are shutting down, closing gatt") - gatt = null - g.close() // Finish closing our gatt here - } else { - // cancel any queued ops if we were already connected - val oldstate = state - state = newState - if (oldstate == BluetoothProfile.STATE_CONNECTED) { - info("Lost connection - aborting current work: $currentWork") - - // If we get a disconnect, just try again otherwise fail all current operations - // Note: if no work is pending (likely) we also just totally teardown and restart the connection, because we won't be - // throwing a lost connection exception to any worker. - if (autoReconnect && (currentWork == null || currentWork?.isConnect() == true)) - dropAndReconnect() - else - lostConnection("lost connection") - } else if (status == 133) { - // We were not previously connected and we just failed with our non-auto connection attempt. Therefore we now need - // to do an autoconnection attempt. When that attempt succeeds/fails the normal callbacks will be called - - // Note: To workaround https://issuetracker.google.com/issues/36995652 - // Always call BluetoothDevice#connectGatt() with autoConnect=false - // (the race condition does not affect that case). If that connection times out - // you will get a callback with status=133. Then call BluetoothGatt#connect() - // to initiate a background connection. - if (autoConnect) { - warn("Failed on non-auto connect, falling back to auto connect attempt") - closeGatt() // Close the old non-auto connection - lowLevelConnect(true) - } - } else if (status == 147) { - info("got 147, calling lostConnection()") - lostConnection("code 147") + // If autoconnect is on and this connect attempt failed, hopefully some future attempt will + // succeed + if (status != BluetoothGatt.GATT_SUCCESS && autoConnect) { + errormsg("Connect attempt failed $status, not calling connect completion handler...") + } else { + completeWork(status, Unit) } + } - if (status == 257) { // mystery error code when phone is hung - //throw Exception("Mystery bluetooth failure - debug me") - restartBle() + BluetoothProfile.STATE_DISCONNECTED -> { + if (gatt == null) { + errormsg("No gatt: ignoring connection state $newState, status $status") + } else if (isClosing) { + info("Got disconnect because we are shutting down, closing gatt") + gatt = null + g.close() // Finish closing our gatt here + } else { + // cancel any queued ops if we were already connected + val oldstate = state + state = newState + if (oldstate == BluetoothProfile.STATE_CONNECTED) { + info("Lost connection - aborting current work: $currentWork") + + // If we get a disconnect, just try again otherwise fail all current operations + // Note: if no work is pending (likely) we also just totally teardown and restart + // the connection, because we won't be + // throwing a lost connection exception to any worker. + if (autoConnect && (currentWork == null || currentWork?.isConnect() == true)) { + dropAndReconnect() + } else { + lostConnection("lost connection") + } + } else if (status == 133) { + // We were not previously connected and we just failed with our non-auto connection + // attempt. Therefore we now need + // to do an autoconnection attempt. When that attempt succeeds/fails the normal + // callbacks will be called + + // Note: To workaround https://issuetracker.google.com/issues/36995652 + // Always call BluetoothDevice#connectGatt() with autoConnect=false + // (the race condition does not affect that case). If that connection times out + // you will get a callback with status=133. Then call BluetoothGatt#connect() + // to initiate a background connection. + if (autoConnect) { + warn("Failed on non-auto connect, falling back to auto connect attempt") + closeGatt() // Close the old non-auto connection + lowLevelConnect(true) + } + } else if (status == 147) { + info("got 147, calling lostConnection()") + lostConnection("code 147") + } + + if (status == 257) { // mystery error code when phone is hung + // throw Exception("Mystery bluetooth failure - debug me") + restartBle() + } } } } } - } - override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { - // For testing lie and claim failure - completeWork(status, Unit) - } - - override fun onCharacteristicRead( - gatt: BluetoothGatt, - characteristic: BluetoothGattCharacteristic, - status: Int - ) { - completeWork(status, characteristic) - } - - override fun onReliableWriteCompleted(gatt: BluetoothGatt, status: Int) { - completeWork(status, Unit) - } - - override fun onCharacteristicWrite( - gatt: BluetoothGatt, - characteristic: BluetoothGattCharacteristic, - status: Int - ) { - val reliable = currentReliableWrite - if (reliable != null) - if (!characteristic.value.contentEquals(reliable)) { - errormsg("A reliable write failed!") - gatt.abortReliableWrite() - completeWork( - STATUS_RELIABLE_WRITE_FAILED, - characteristic - ) // skanky code to indicate failure - } else { - logAssert(gatt.executeReliableWrite()) - // After this execute reliable completes - we can continue with normal operations (see onReliableWriteCompleted) - } - else // Just a standard write - do the normal flow - completeWork(status, characteristic) - } - - override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { - // Alas, passing back an Int mtu isn't working and since I don't really care what MTU - // the device was willing to let us have I'm just punting and returning Unit - if (isSettingMtu) + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + // For testing lie and claim failure completeWork(status, Unit) - else - errormsg("Ignoring bogus onMtuChanged") - } + } - /** - * Callback triggered as a result of a remote characteristic notification. - * - * @param gatt GATT client the characteristic is associated with - * @param characteristic Characteristic that has been updated as a result of a remote - * notification event. - */ - override fun onCharacteristicChanged( - gatt: BluetoothGatt, - characteristic: BluetoothGattCharacteristic - ) { - val handler = notifyHandlers.get(characteristic.uuid) - if (handler == null) - warn("Received notification from $characteristic, but no handler registered") - else { - exceptionReporter { - handler(characteristic) + override fun onCharacteristicRead( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int, + ) { + completeWork(status, characteristic) + } + + override fun onReliableWriteCompleted(gatt: BluetoothGatt, status: Int) { + completeWork(status, Unit) + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + override fun onCharacteristicWrite( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int, + ) { + val reliable = currentReliableWrite + if (reliable != null) { + if (!characteristic.value.contentEquals(reliable)) { + errormsg("A reliable write failed!") + gatt.abortReliableWrite() + completeWork(STATUS_RELIABLE_WRITE_FAILED, characteristic) // skanky code to indicate failure + } else { + logAssert(gatt.executeReliableWrite()) + // After this execute reliable completes - we can continue with normal operations (see + // onReliableWriteCompleted) + } + } else { // Just a standard write - do the normal flow + completeWork(status, characteristic) } } - } - /** - * Callback indicating the result of a descriptor write operation. - * - * @param gatt GATT client invoked [BluetoothGatt.writeDescriptor] - * @param descriptor Descriptor that was writte to the associated remote device. - * @param status The result of the write operation [BluetoothGatt.GATT_SUCCESS] if the - * operation succeeds. - */ - override fun onDescriptorWrite( - gatt: BluetoothGatt, - descriptor: BluetoothGattDescriptor, - status: Int - ) { - completeWork(status, descriptor) - } + override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { + // Alas, passing back an Int mtu isn't working and since I don't really care what MTU + // the device was willing to let us have I'm just punting and returning Unit + if (isSettingMtu) { + completeWork(status, Unit) + } else { + errormsg("Ignoring bogus onMtuChanged") + } + } - /** - * Callback reporting the result of a descriptor read operation. - * - * @param gatt GATT client invoked [BluetoothGatt.readDescriptor] - * @param descriptor Descriptor that was read from the associated remote device. - * @param status [BluetoothGatt.GATT_SUCCESS] if the read operation was completed - * successfully - */ - override fun onDescriptorRead( - gatt: BluetoothGatt, - descriptor: BluetoothGattDescriptor, - status: Int - ) { - completeWork(status, descriptor) + /** + * Callback triggered as a result of a remote characteristic notification. + * + * @param gatt GATT client the characteristic is associated with + * @param characteristic Characteristic that has been updated as a result of a remote notification event. + */ + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { + val handler = notifyHandlers.get(characteristic.uuid) + if (handler == null) { + warn("Received notification from $characteristic, but no handler registered") + } else { + exceptionReporter { handler(characteristic) } + } + } + + /** + * Callback indicating the result of a descriptor write operation. + * + * @param gatt GATT client invoked [BluetoothGatt.writeDescriptor] + * @param descriptor Descriptor that was writte to the associated remote device. + * @param status The result of the write operation [BluetoothGatt.GATT_SUCCESS] if the operation succeeds. + */ + override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { + completeWork(status, descriptor) + } + + /** + * Callback reporting the result of a descriptor read operation. + * + * @param gatt GATT client invoked [BluetoothGatt.readDescriptor] + * @param descriptor Descriptor that was read from the associated remote device. + * @param status [BluetoothGatt.GATT_SUCCESS] if the read operation was completed successfully + */ + override fun onDescriptorRead(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { + completeWork(status, descriptor) + } } - } // To test loss of BLE faults we can randomly fail a certain % of all work items. We // skip this for "connect" items because the handling for connection failure is special @@ -334,7 +384,7 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD private var activeTimeout: Job? = null - /// If we have work we can do, start doing it. + // / If we have work we can do, start doing it. private fun startNewWork() { logAssert(currentWork == null) @@ -343,23 +393,18 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD currentWork = newWork if (newWork.timeoutMillis != 0L) { - - activeTimeout = serviceScope.launch { - // debug("Starting failsafe timer ${newWork.timeoutMillis}") - delay(newWork.timeoutMillis) - errormsg("Failsafe BLE timer expired!") - completeWork( - STATUS_TIMEOUT, - Unit - ) // Throw an exception in that work - } + activeTimeout = + serviceScope.launch { + // debug("Starting failsafe timer ${newWork.timeoutMillis}") + delay(newWork.timeoutMillis) + errormsg("Failsafe BLE timer expired!") + completeWork(STATUS_TIMEOUT, Unit) // Throw an exception in that work + } } - isSettingMtu = - false // Most work is not doing MTU stuff, the work that is will re set this flag + isSettingMtu = false // Most work is not doing MTU stuff, the work that is will re set this flag - val failThis = - simFailures && !newWork.isConnect() && failRandom.nextInt(100) < failPercent + val failThis = simFailures && !newWork.isConnect() && failRandom.nextInt(100) < failPercent if (failThis) { errormsg("Simulating random work failure!") @@ -368,42 +413,27 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD val started = newWork.startWork() if (!started) { errormsg("Failed to start work, returned error status") - completeWork( - STATUS_NOSTART, - Unit - ) // abandon the current attempt and try for another + completeWork(STATUS_NOSTART, Unit) // abandon the current attempt and try for another } } } } - private fun queueWork( - tag: String, - cont: Continuation, - timeout: Long, - initFn: () -> Boolean - ) { - val btCont = - BluetoothContinuation( - tag, - cont, - timeout, - initFn - ) + private fun queueWork(tag: String, cont: Continuation, timeout: Long, initFn: () -> Boolean) { + val btCont = BluetoothContinuation(tag, cont, timeout, initFn) synchronized(workQueue) { debug("Enqueuing work: ${btCont.tag}") workQueue.add(btCont) // if we don't have any outstanding operations, run first item in queue - if (currentWork == null) + if (currentWork == null) { startNewWork() + } } } - /** - * Stop any current work - */ + /** Stop any current work */ private fun stopCurrentWork() { activeTimeout?.let { it.cancel() @@ -412,12 +442,11 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD currentWork = null } - /** - * Called from our big GATT callback, completes the current job and then schedules a new one - */ + /** Called from our big GATT callback, completes the current job and then schedules a new one */ private fun completeWork(status: Int, res: T) { exceptionReporter { - // We might unexpectedly fail inside here, but we don't want to pass that exception back up to the bluetooth GATT layer + // We might unexpectedly fail inside here, but we don't want to pass that exception back up to the bluetooth + // GATT layer // startup next job in queue before calling the completion handler val work = @@ -432,26 +461,22 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD w } - if (work == null) + if (work == null) { warn("wor completed, but we already killed it via failsafetimer? status=$status, res=$res") - else { + } else { // debug("work ${work.tag} is completed, resuming status=$status, res=$res") - if (status != 0) + if (status != 0) { work.completion.resumeWithException( - BLEStatusException( - status, - "Bluetooth status=$status while doing ${work.tag}" - ) + BLEStatusException(status, "Bluetooth status=$status while doing ${work.tag}"), ) - else + } else { work.completion.resume(Result.success(res) as Result) + } } } } - /** - * Something went wrong, abort all queued - */ + /** Something went wrong, abort all queued */ private fun failAllWork(ex: Exception) { synchronized(workQueue) { warn("Failing ${workQueue.size} works, because ${ex.message}") @@ -459,10 +484,7 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD try { it.completion.resumeWithException(ex) } catch (ex: Exception) { - errormsg( - "Mystery exception, why were we informed about our own exceptions?", - ex - ) + errormsg("Mystery exception, why were we informed about our own exceptions?", ex) } } workQueue.clear() @@ -470,7 +492,7 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD } } - /// helper glue to make sync continuations and then wait for the result + // / helper glue to make sync continuations and then wait for the result private fun makeSync(wrappedFn: (SyncContinuation) -> Unit): T { val cont = SyncContinuation() wrappedFn(cont) @@ -480,28 +502,21 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD // Is the gatt trying to repeatedly connect as needed? private var autoConnect = false - /// True if the current active connection is auto (possible for this to be false but autoConnect to be true - /// if we are in the first non-automated lowLevel connect. + // / True if the current active connection is auto (possible for this to be false but autoConnect to be true + // / if we are in the first non-automated lowLevel connect. private var currentConnectIsAuto = false + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) private fun lowLevelConnect(autoNow: Boolean): BluetoothGatt? { currentConnectIsAuto = autoNow logAssert(gatt == null) - val g = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - device.connectGatt( - context, - autoNow, - gattCallback, - BluetoothDevice.TRANSPORT_LE - ) - } else { - device.connectGatt( - context, - autoNow, - gattCallback - ) - } + val g = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + device.connectGatt(context, autoNow, gattCallback, BluetoothDevice.TRANSPORT_LE) + } else { + device.connectGatt(context, autoNow, gattCallback) + } gatt = g return g @@ -512,16 +527,12 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD // see https://stackoverflow.com/questions/40156699/which-correct-flag-of-autoconnect-in-connectgatt-of-ble for // more info. // Otherwise if you pass in false, it will try to connect now and will timeout and fail in 30 seconds. - private fun queueConnect( - autoConnect: Boolean = false, - cont: Continuation, - timeout: Long = 0 - ) { + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private fun queueConnect(autoConnect: Boolean = false, cont: Continuation, timeout: Long = 0) { this.autoConnect = autoConnect // assert(gatt == null) this now might be !null with our new reconnect support queueWork("connect", cont, timeout) { - // Note: To workaround https://issuetracker.google.com/issues/36995652 // Always call BluetoothDevice#connectGatt() with autoConnect=false // (the race condition does not affect that case). If that connection times out @@ -535,36 +546,36 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD /** * start a connection attempt. * - * Note: if autoConnect is true, the callback you provide will be kept around _even after the connection is complete. - * If we ever lose the connection, this class will immediately requque the attempt (after canceling - * any outstanding queued operations). + * Note: if autoConnect is true, the callback you provide will be kept around _even after the connection is + * complete. If we ever lose the connection, this class will immediately requque the attempt (after canceling any + * outstanding queued operations). * * So you should expect your callback might be called multiple times, each time to reestablish a new connection. */ - fun asyncConnect( - autoConnect: Boolean = false, - cb: (Result) -> Unit, - lostConnectCb: () -> Unit - ) { + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + fun asyncConnect(autoConnect: Boolean = false, cb: (Result) -> Unit, lostConnectCb: () -> Unit) { logAssert(workQueue.isEmpty()) - if (currentWork != null) + if (currentWork != null) { throw AssertionError("currentWork was not null: $currentWork") + } lostConnectCallback = lostConnectCb - connectionCallback = if (autoConnect) - cb - else - null + connectionCallback = + if (autoConnect) { + cb + } else { + null + } queueConnect(autoConnect, CallbackContinuation(cb)) } - /// Restart any previous connect attempts + // / Restart any previous connect attempts + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + @Suppress("UnusedPrivateMember") private fun reconnect() { // closeGatt() // Get rid of any old gatt - connectionCallback?.let { cb -> - queueConnect(true, CallbackContinuation(cb)) - } + connectionCallback?.let { cb -> queueConnect(true, CallbackContinuation(cb)) } } private fun lostConnection(reason: String) { @@ -577,7 +588,7 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD https://stackoverflow.com/questions/37965337/what-exactly-does-androids-bluetooth-autoconnect-parameter-do?rq=1 closeConnection() - */ + */ failAllWork(BLEException(reason)) // Cancel any notifications - because when the device comes back it might have forgotten about us @@ -589,7 +600,8 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD } } - /// Drop our current connection and then requeue a connect as needed + // / Drop our current connection and then requeue a connect as needed + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) private fun dropAndReconnect() { lostConnection("lost connection, reconnecting") @@ -599,155 +611,161 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD debug("queuing a reconnection callback") assert(currentWork == null) - if (!currentConnectIsAuto) { // we must have been running during that 1-time manual connect, switch to auto-mode from now on + if ( + !currentConnectIsAuto + ) { // we must have been running during that 1-time manual connect, switch to auto-mode from now on closeGatt() // Close the old non-auto connection lowLevelConnect(true) } - // note - we don't need an init fn (because that would normally redo the connectGatt call - which we don't need) - queueWork("reconnect", CallbackContinuation(cb), 0) { -> true } + // note - we don't need an init fn (because that would normally redo the connectGatt call - which we don't + // need) + queueWork("reconnect", CallbackContinuation(cb), 0) { true } } else { debug("No connectionCallback registered") } } - fun connect(autoConnect: Boolean = false) = - makeSync { queueConnect(autoConnect, it) } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + fun connect(autoConnect: Boolean = false) = makeSync { queueConnect(autoConnect, it) } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) private fun queueReadCharacteristic( c: BluetoothGattCharacteristic, - cont: Continuation, timeout: Long = 0 + cont: Continuation, + timeout: Long = 0, ) = queueWork("readC ${c.uuid}", cont, timeout) { gatt!!.readCharacteristic(c) } - fun asyncReadCharacteristic( - c: BluetoothGattCharacteristic, - cb: (Result) -> Unit - ) = queueReadCharacteristic(c, CallbackContinuation(cb)) + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + fun asyncReadCharacteristic(c: BluetoothGattCharacteristic, cb: (Result) -> Unit) = + queueReadCharacteristic(c, CallbackContinuation(cb)) - fun readCharacteristic( - c: BluetoothGattCharacteristic, - timeout: Long = timeoutMsec - ): BluetoothGattCharacteristic = - makeSync { queueReadCharacteristic(c, it, timeout) } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + fun readCharacteristic(c: BluetoothGattCharacteristic, timeout: Long = timeoutMsec): BluetoothGattCharacteristic = + makeSync { + queueReadCharacteristic(c, it, timeout) + } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) private fun queueDiscoverServices(cont: Continuation, timeout: Long = 0) { queueWork("discover", cont, timeout) { gatt?.discoverServices() - ?: false // throw BLEException("GATT is null") - if we return false here it is probably because the device is being torn down + ?: false // throw BLEException("GATT is null") - if we return false here it is probably because the + // device is being torn down } } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) fun asyncDiscoverServices(cb: (Result) -> Unit) { queueDiscoverServices(CallbackContinuation(cb)) } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) fun discoverServices() = makeSync { queueDiscoverServices(it) } - /** - * On some phones we receive bogus mtu gatt callbacks, we need to ignore them if we weren't setting the mtu - */ + /** On some phones we receive bogus mtu gatt callbacks, we need to ignore them if we weren't setting the mtu */ private var isSettingMtu = false /** - * mtu operations seem to hang sometimes. To cope with this we have a 5 second timeout before throwing an exception and cancelling the work + * mtu operations seem to hang sometimes. To cope with this we have a 5 second timeout before throwing an exception + * and cancelling the work */ - private fun queueRequestMtu( - len: Int, - cont: Continuation - ) = queueWork("reqMtu", cont, 10 * 1000) { + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private fun queueRequestMtu(len: Int, cont: Continuation) = queueWork("reqMtu", cont, 10 * 1000) { isSettingMtu = true gatt?.requestMtu(len) ?: false } - fun asyncRequestMtu( - len: Int, - cb: (Result) -> Unit - ) { + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + fun asyncRequestMtu(len: Int, cb: (Result) -> Unit) { queueRequestMtu(len, CallbackContinuation(cb)) } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) fun requestMtu(len: Int): Unit = makeSync { queueRequestMtu(len, it) } private var currentReliableWrite: ByteArray? = null + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) private fun queueWriteCharacteristic( c: BluetoothGattCharacteristic, v: ByteArray, - cont: Continuation, timeout: Long = 0 + cont: Continuation, + timeout: Long = 0, ) = queueWork("writeC ${c.uuid}", cont, timeout) { currentReliableWrite = null c.value = v gatt?.writeCharacteristic(c) ?: false } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) fun asyncWriteCharacteristic( c: BluetoothGattCharacteristic, v: ByteArray, - cb: (Result) -> Unit + cb: (Result) -> Unit, ) = queueWriteCharacteristic(c, v, CallbackContinuation(cb)) + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) fun writeCharacteristic( c: BluetoothGattCharacteristic, v: ByteArray, - timeout: Long = timeoutMsec - ): BluetoothGattCharacteristic = - makeSync { queueWriteCharacteristic(c, v, it, timeout) } - - /** Like write, but we use the extra reliable flow documented here: - * https://stackoverflow.com/questions/24485536/what-is-reliable-write-in-ble - */ - private fun queueWriteReliable( - c: BluetoothGattCharacteristic, - cont: Continuation, timeout: Long = 0 - ) = queueWork("rwriteC ${c.uuid}", cont, timeout) { - logAssert(gatt!!.beginReliableWrite()) - currentReliableWrite = c.value.clone() - gatt?.writeCharacteristic(c) ?: false - } - - fun asyncWriteReliable( - c: BluetoothGattCharacteristic, - cb: (Result) -> Unit - ) = queueWriteReliable(c, CallbackContinuation(cb)) - - fun writeReliable(c: BluetoothGattCharacteristic): Unit = - makeSync { queueWriteReliable(c, it) } - - private fun queueWriteDescriptor( - c: BluetoothGattDescriptor, - cont: Continuation, timeout: Long = 0 - ) = queueWork("writeD", cont, timeout) { gatt?.writeDescriptor(c) ?: false } - - fun asyncWriteDescriptor( - c: BluetoothGattDescriptor, - cb: (Result) -> Unit - ) = queueWriteDescriptor(c, CallbackContinuation(cb)) + timeout: Long = timeoutMsec, + ): BluetoothGattCharacteristic = makeSync { queueWriteCharacteristic(c, v, it, timeout) } /** - * Some old androids have a bug where calling disconnect doesn't guarantee that the onConnectionStateChange callback gets called - * but the only safe way to call gatt.close is from that callback. So we set a flag once we start closing and then poll - * until we see the callback has set gatt to null (indicating the CALLBACK has close the gatt). If the timeout expires we assume the bug - * has occurred, and we manually close the gatt here. + * Like write, but we use the extra reliable flow documented here: + * https://stackoverflow.com/questions/24485536/what-is-reliable-write-in-ble + */ + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private fun queueWriteReliable(c: BluetoothGattCharacteristic, cont: Continuation, timeout: Long = 0) = + queueWork("rwriteC ${c.uuid}", cont, timeout) { + logAssert(gatt!!.beginReliableWrite()) + currentReliableWrite = c.value.clone() + gatt?.writeCharacteristic(c) ?: false + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + fun asyncWriteReliable(c: BluetoothGattCharacteristic, cb: (Result) -> Unit) = + queueWriteReliable(c, CallbackContinuation(cb)) + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + fun writeReliable(c: BluetoothGattCharacteristic): Unit = makeSync { queueWriteReliable(c, it) } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private fun queueWriteDescriptor( + c: BluetoothGattDescriptor, + cont: Continuation, + timeout: Long = 0, + ) = queueWork("writeD", cont, timeout) { gatt?.writeDescriptor(c) ?: false } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + fun asyncWriteDescriptor(c: BluetoothGattDescriptor, cb: (Result) -> Unit) = + queueWriteDescriptor(c, CallbackContinuation(cb)) + + /** + * Some old androids have a bug where calling disconnect doesn't guarantee that the onConnectionStateChange callback + * gets called but the only safe way to call gatt.close is from that callback. So we set a flag once we start + * closing and then poll until we see the callback has set gatt to null (indicating the CALLBACK has close the + * gatt). If the timeout expires we assume the bug has occurred, and we manually close the gatt here. * - * Log of typical failure - * 06-29 08:47:15.035 29788-30155/com.geeksville.mesh D/BluetoothGatt: cancelOpen() - device: 24:62:AB:F8:40:9A - 06-29 08:47:15.036 29788-30155/com.geeksville.mesh D/BluetoothGatt: close() - 06-29 08:47:15.037 29788-30155/com.geeksville.mesh D/BluetoothGatt: unregisterApp() - mClientIf=5 - 06-29 08:47:15.037 29788-29813/com.geeksville.mesh D/BluetoothGatt: onClientConnectionState() - status=0 clientIf=5 device=24:62:AB:F8:40:9A - 06-29 08:47:15.037 29788-29813/com.geeksville.mesh W/BluetoothGatt: Unhandled exception in callback - java.lang.NullPointerException: Attempt to invoke virtual method 'void android.bluetooth.BluetoothGattCallback.onConnectionStateChange(android.bluetooth.BluetoothGatt, int, int)' on a null object reference - at android.bluetooth.BluetoothGatt$1.onClientConnectionState(BluetoothGatt.java:182) - at android.bluetooth.IBluetoothGattCallback$Stub.onTransact(IBluetoothGattCallback.java:70) - at android.os.Binder.execTransact(Binder.java:446) + * Log of typical failure 06-29 08:47:15.035 29788-30155/com.geeksville.mesh D/BluetoothGatt: cancelOpen() - device: + * 24:62:AB:F8:40:9A 06-29 08:47:15.036 29788-30155/com.geeksville.mesh D/BluetoothGatt: close() 06-29 08:47:15.037 + * 29788-30155/com.geeksville.mesh D/BluetoothGatt: unregisterApp() - mClientIf=5 06-29 08:47:15.037 + * 29788-29813/com.geeksville.mesh D/BluetoothGatt: onClientConnectionState() - status=0 clientIf=5 + * device=24:62:AB:F8:40:9A 06-29 08:47:15.037 29788-29813/com.geeksville.mesh W/BluetoothGatt: Unhandled exception + * in callback java.lang.NullPointerException: Attempt to invoke virtual method 'void + * android.bluetooth.BluetoothGattCallback.onConnectionStateChange(android.bluetooth.BluetoothGatt, int, int)' on a + * null object reference at android.bluetooth.BluetoothGatt$1.onClientConnectionState(BluetoothGatt.java:182) at + * android.bluetooth.IBluetoothGattCallback$Stub.onTransact(IBluetoothGattCallback.java:70) at + * android.os.Binder.execTransact(Binder.java:446) * * per https://github.com/don/cordova-plugin-ble-central/issues/473#issuecomment-367687575 */ - @Volatile - private var isClosing = false + @Volatile private var isClosing = false /** Close just the GATT device but keep our pending callbacks active */ + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) fun closeGatt() { - gatt?.let { g -> info("Closing our GATT connection") isClosing = true @@ -763,13 +781,13 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD gatt?.let { g2 -> warn("Android onConnectionStateChange did not run, manually closing") - gatt = - null // clear gat before calling close, bcause close might throw dead object exception + gatt = null // clear gat before calling close, bcause close might throw dead object exception g2.close() } } catch (ex: NullPointerException) { - // Attempt to invoke virtual method 'com.android.bluetooth.gatt.AdvertiseClient com.android.bluetooth.gatt.AdvertiseManager.getAdvertiseClient(int)' on a null object reference - //com.geeksville.mesh.service.SafeBluetooth.closeGatt + // Attempt to invoke virtual method 'com.android.bluetooth.gatt.AdvertiseClient + // com.android.bluetooth.gatt.AdvertiseManager.getAdvertiseClient(int)' on a null object reference + // com.geeksville.mesh.service.SafeBluetooth.closeGatt warn("Ignoring NPE in close - probably buggy Samsung BLE") } catch (ex: DeadObjectException) { warn("Ignoring dead object exception, probably bluetooth was just disabled") @@ -780,11 +798,13 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD } /** - * Close down any existing connection, any existing calls (including async connects will be - * cancelled and you'll need to recall connect to use this againt + * Close down any existing connection, any existing calls (including async connects will be cancelled and you'll + * need to recall connect to use this againt */ + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) fun closeConnection() { - // Set these to null _before_ calling gatt.disconnect(), because we don't want the old lostConnectCallback to get called + // Set these to null _before_ calling gatt.disconnect(), because we don't want the old lostConnectCallback to + // get called lostConnectCallback = null connectionCallback = null @@ -796,34 +816,34 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD failAllWork(BLEConnectionClosing()) } - /** - * Close and destroy this SafeBluetooth instance. You'll need to make a new instance before using it again - */ + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + /** Close and destroy this SafeBluetooth instance. You'll need to make a new instance before using it again */ override fun close() { closeConnection() // context.unregisterReceiver(btStateReceiver) } - - /// asyncronously turn notification on/off for a characteristic - fun setNotify( - c: BluetoothGattCharacteristic, - enable: Boolean, - onChanged: (BluetoothGattCharacteristic) -> Unit - ) { + // / asyncronously turn notification on/off for a characteristic + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + fun setNotify(c: BluetoothGattCharacteristic, enable: Boolean, onChanged: (BluetoothGattCharacteristic) -> Unit) { debug("starting setNotify(${c.uuid}, $enable)") notifyHandlers[c.uuid] = onChanged // c.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT gatt!!.setCharacteristicNotification(c, enable) // per https://stackoverflow.com/questions/27068673/subscribe-to-a-ble-gatt-notification-android - val descriptor: BluetoothGattDescriptor = c.getDescriptor(configurationDescriptorUUID) - ?: throw BLEException("Notify descriptor not found for ${c.uuid}") // This can happen on buggy BLE implementations + val descriptor: BluetoothGattDescriptor = + c.getDescriptor(configurationDescriptorUUID) + ?: throw BLEException( + "Notify descriptor not found for ${c.uuid}", + ) // This can happen on buggy BLE implementations descriptor.value = - if (enable) BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE else BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE - asyncWriteDescriptor(descriptor) { - debug("Notify enable=$enable completed") - } + if (enable) { + BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + } else { + BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE + } + asyncWriteDescriptor(descriptor) { debug("Notify enable=$enable completed") } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index d276b3923..f7b17ea0f 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -17,6 +17,8 @@ package com.geeksville.mesh.ui +import android.Manifest +import android.os.Build import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background @@ -107,6 +109,9 @@ import com.geeksville.mesh.ui.node.components.NodeChip import com.geeksville.mesh.ui.node.components.NodeMenuAction import com.geeksville.mesh.ui.radioconfig.RadioConfigMenuActions import com.geeksville.mesh.ui.sharing.SharedContactDialog +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState enum class TopLevelDestination(@StringRes val label: Int, val icon: ImageVector, val route: Route) { Contacts(R.string.contacts, Icons.AutoMirrored.TwoTone.Chat, ContactsRoutes.ContactsGraph), @@ -131,7 +136,7 @@ enum class TopLevelDestination(@StringRes val label: Int, val icon: ImageVector, } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun MainScreen( @@ -143,6 +148,16 @@ fun MainScreen( val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle() val localConfig by uIViewModel.localConfig.collectAsStateWithLifecycle() val requestChannelSet by uIViewModel.requestChannelSet.collectAsStateWithLifecycle() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val notificationPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) + LaunchedEffect(connectionState, notificationPermissionState) { + if (connectionState.isConnected() && !notificationPermissionState.status.isGranted) { + notificationPermissionState.launchPermissionRequest() + } + } + } + if (connectionState.isConnected()) { requestChannelSet?.let { newChannelSet -> ScannedQrCodeDialog(uIViewModel, newChannelSet) } } @@ -316,7 +331,7 @@ private fun VersionChecks(viewModel: UIViewModel) { } } // Check if the device is running an old app version or firmware version - LaunchedEffect(connectionState, myNodeInfo, firmwareEdition) { + LaunchedEffect(connectionState, myNodeInfo) { if (connectionState == MeshService.ConnectionState.CONNECTED) { myNodeInfo?.let { info -> val isOld = info.minAppVersion > BuildConfig.VERSION_CODE diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt index 7fe123c2a..9cec652f1 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt @@ -17,14 +17,13 @@ package com.geeksville.mesh.ui.connections +import android.Manifest import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.net.InetAddresses import android.os.Build import android.util.Patterns -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -82,14 +81,9 @@ import com.geeksville.mesh.R import com.geeksville.mesh.android.BuildUtils.debug import com.geeksville.mesh.android.BuildUtils.info import com.geeksville.mesh.android.BuildUtils.reportError -import com.geeksville.mesh.android.BuildUtils.warn import com.geeksville.mesh.android.GeeksvilleApplication -import com.geeksville.mesh.android.getBluetoothPermissions -import com.geeksville.mesh.android.getLocationPermissions import com.geeksville.mesh.android.gpsDisabled -import com.geeksville.mesh.android.hasLocationPermission import com.geeksville.mesh.android.isGooglePlayAvailable -import com.geeksville.mesh.android.permissionMissing import com.geeksville.mesh.model.BTScanModel import com.geeksville.mesh.model.BluetoothViewModel import com.geeksville.mesh.model.DeviceListEntry @@ -110,6 +104,8 @@ import com.geeksville.mesh.ui.node.components.NodeMenuAction import com.geeksville.mesh.ui.radioconfig.RadioConfigViewModel import com.geeksville.mesh.ui.radioconfig.components.PacketResponseStateDialog import com.geeksville.mesh.ui.sharing.SharedContactDialog +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState import kotlinx.coroutines.delay fun String?.isIPAddress(): Boolean = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { @@ -119,6 +115,11 @@ fun String?.isIPAddress(): Boolean = if (Build.VERSION.SDK_INT < Build.VERSION_C InetAddresses.isNumericAddress(this.toString()) } +/** + * Composable screen for managing device connections (BLE, TCP, USB). It handles permission requests for location and + * displays connection status. + */ +@OptIn(ExperimentalPermissionsApi::class) @Suppress("CyclomaticComplexMethod", "LongMethod", "MagicNumber") @Composable fun ConnectionsScreen( @@ -142,7 +143,7 @@ fun ConnectionsScreen( val app = (context.applicationContext as GeeksvilleApplication) val info by uiViewModel.myNodeInfo.collectAsState() val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle() - val bluetoothEnabled by bluetoothViewModel.enabled.observeAsState() + val bluetoothEnabled by bluetoothViewModel.enabled.collectAsStateWithLifecycle(false) val regionUnset = currentRegion == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET && connectionState == MeshService.ConnectionState.CONNECTED @@ -180,7 +181,7 @@ fun ConnectionsScreen( } } LaunchedEffect(bluetoothEnabled) { - if (bluetoothEnabled == false) { + if (!bluetoothEnabled) { uiViewModel.showSnackbar(context.getString(R.string.bluetooth_disabled)) } } @@ -196,52 +197,9 @@ fun ConnectionsScreen( var showScanDialog by remember { mutableStateOf(false) } val scanResults by scanModel.scanResult.observeAsState(emptyMap()) - // State for the location permission rationale dialog - var showLocationRationaleDialog by remember { mutableStateOf(false) } - - // State for the Bluetooth permission rationale dialog - var showBluetoothRationaleDialog by remember { mutableStateOf(false) } - // State for the Report Bug dialog var showReportBugDialog by remember { mutableStateOf(false) } - // Remember the permission launchers - val requestLocationPermissionLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestMultiplePermissions(), - onResult = { permissions -> - if (permissions.entries.all { it.value }) { - uiViewModel.setProvideLocation(true) - uiViewModel.meshService?.startProvideLocation() - } else { - debug("User denied location permission") - uiViewModel.showSnackbar(context.getString(R.string.why_background_required)) - } - bluetoothViewModel.permissionsUpdated() - }, - ) - - val requestBluetoothPermissionLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestMultiplePermissions(), - onResult = { permissions -> - if (permissions.entries.all { it.value }) { - info("Bluetooth permissions granted") - // We need to call the scan function which is in the Fragment - // Since we can't directly call scanLeDevice() from Composable, - // we might need to rethink how scanning is triggered or - // pass the scan trigger as a lambda. - // For now, let's assume we trigger the scan outside the Composable - // after permissions are granted. We can add a callback to the ViewModel. - scanModel.startScan() - } else { - warn("Bluetooth permissions denied") - uiViewModel.showSnackbar(context.permissionMissing) - } - bluetoothViewModel.permissionsUpdated() - }, - ) - // Observe scan results to show the dialog if (scanResults.isNotEmpty()) { showScanDialog = true @@ -264,6 +222,28 @@ fun ConnectionsScreen( if (showSharedContact != null) { SharedContactDialog(contact = showSharedContact, onDismiss = { showSharedContact = null }) } + + val locationPermissionsState = + rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) + val provideLocation by uiViewModel.provideLocation.collectAsState(false) + + LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) { + if (provideLocation) { + if (locationPermissionsState.allPermissionsGranted) { + if (!isGpsDisabled) { + uiViewModel.meshService?.startProvideLocation() + } else { + uiViewModel.showSnackbar(context.getString(R.string.location_disabled)) + } + } else { + // Request permissions if not granted and user wants to provide location + locationPermissionsState.launchMultiplePermissionRequest() + } + } else { + uiViewModel.meshService?.stopProvideLocation() + } + } + Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { Text( @@ -379,8 +359,6 @@ fun ConnectionsScreen( connectionState = connectionState, btDevices = bleDevices, selectedDevice = selectedDevice, - showBluetoothRationaleDialog = { showBluetoothRationaleDialog = true }, - requestBluetoothPermission = { requestBluetoothPermissionLauncher.launch(it) }, scanModel = scanModel, ) } @@ -413,17 +391,6 @@ fun ConnectionsScreen( } } AnimatedVisibility(isConnected) { - val provideLocation by uiViewModel.provideLocation.collectAsState(false) - LaunchedEffect(provideLocation) { - if (provideLocation) { - if (!context.hasLocationPermission()) { - debug("Requesting location permission for providing location") - showLocationRationaleDialog = true - } else if (isGpsDisabled) { - uiViewModel.showSnackbar(context.getString(R.string.location_disabled)) - } - } - } Row( modifier = Modifier.fillMaxWidth() @@ -436,8 +403,10 @@ fun ConnectionsScreen( verticalAlignment = Alignment.CenterVertically, ) { Checkbox( + // Checked state driven by receivingLocationUpdates for visual feedback + // but toggle action drives provideLocation checked = receivingLocationUpdates, - onCheckedChange = null, + onCheckedChange = null, // Toggleable handles the change enabled = !isGpsDisabled, // Disable if GPS is disabled ) Text( @@ -561,58 +530,6 @@ fun ConnectionsScreen( } } - // Compose Location Permission Rationale Dialog - if (showLocationRationaleDialog) { - AlertDialog( - onDismissRequest = { showLocationRationaleDialog = false }, - title = { Text(stringResource(R.string.background_required)) }, - text = { Text(stringResource(R.string.why_background_required)) }, - confirmButton = { - Button( - onClick = { - showLocationRationaleDialog = false - if (!context.hasLocationPermission()) { - requestLocationPermissionLauncher.launch(context.getLocationPermissions()) - } - }, - ) { - Text(stringResource(R.string.accept)) - } - }, - dismissButton = { - Button(onClick = { showLocationRationaleDialog = false }) { Text(stringResource(R.string.cancel)) } - }, - ) - } - - // Compose Bluetooth Permission Rationale Dialog - if (showBluetoothRationaleDialog) { - val bluetoothPermissions = context.getBluetoothPermissions() - AlertDialog( - onDismissRequest = { showBluetoothRationaleDialog = false }, - title = { Text(stringResource(R.string.required_permissions)) }, - text = { Text(stringResource(R.string.permission_missing_31)) }, - confirmButton = { - Button( - onClick = { - showBluetoothRationaleDialog = false - if (bluetoothPermissions.isNotEmpty()) { - requestBluetoothPermissionLauncher.launch(bluetoothPermissions) - } else { - // If somehow no permissions are required, just scan - scanModel.startScan() - } - }, - ) { - Text(stringResource(R.string.okay)) - } - }, - dismissButton = { - Button(onClick = { showBluetoothRationaleDialog = false }) { Text(stringResource(R.string.cancel)) } - }, - ) - } - // Compose Report Bug Dialog if (showReportBugDialog) { AlertDialog( diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt index 6a7606d34..013907ebf 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt @@ -17,7 +17,9 @@ package com.geeksville.mesh.ui.connections.components -import android.app.Activity +import android.Manifest +import android.os.Build +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -32,90 +34,145 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.core.app.ActivityCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.R -import com.geeksville.mesh.android.getBluetoothPermissions import com.geeksville.mesh.model.BTScanModel import com.geeksville.mesh.model.DeviceListEntry import com.geeksville.mesh.service.MeshService +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +/** + * Composable that displays a list of Bluetooth Low Energy (BLE) devices and allows scanning. It handles Bluetooth + * permissions using `accompanist-permissions`. + * + * @param connectionState The current connection state of the MeshService. + * @param btDevices List of discovered BLE devices. + * @param selectedDevice The full address of the currently selected device. + * @param scanModel The ViewModel responsible for Bluetooth scanning logic. + */ +@OptIn(ExperimentalPermissionsApi::class) @Suppress("LongMethod") @Composable fun BLEDevices( connectionState: MeshService.ConnectionState, btDevices: List, selectedDevice: String, - showBluetoothRationaleDialog: () -> Unit, - requestBluetoothPermission: (Array) -> Unit, scanModel: BTScanModel, ) { - val context = LocalContext.current + LocalContext.current // Used implicitly by stringResource val isScanning by scanModel.spinner.collectAsStateWithLifecycle(false) + + // Define permissions needed for Bluetooth scanning based on Android version. + val bluetoothPermissionsList = remember { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) + } else { + // ACCESS_FINE_LOCATION is required for Bluetooth scanning on pre-S devices. + listOf(Manifest.permission.ACCESS_FINE_LOCATION) + } + } + + val permissionsState = + rememberMultiplePermissionsState( + permissions = bluetoothPermissionsList, + onPermissionsResult = { + if (it.values.all { granted -> granted }) { + scanModel.startScan() + scanModel.refreshPermissions() + } else { + // If permissions are not granted, we can show a message or handle it accordingly. + } + }, + ) + Text( text = stringResource(R.string.bluetooth), style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(vertical = 8.dp), ) - btDevices.forEach { device -> - DeviceListItem( - connectionState = connectionState, - device = device, - selected = device.fullAddress == selectedDevice, - onSelect = { scanModel.onSelected(device) }, - modifier = Modifier, - ) - } - if (isScanning) { - Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalAlignment = CenterHorizontally) { - CircularProgressIndicator(modifier = Modifier.size(96.dp)) - Text( - text = stringResource(R.string.scanning), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(vertical = 8.dp), + + if (permissionsState.allPermissionsGranted) { + btDevices.forEach { device -> + DeviceListItem( + connectionState = connectionState, + device = device, + selected = device.fullAddress == selectedDevice, + onSelect = { scanModel.onSelected(device) }, + modifier = Modifier, ) } - } else if (btDevices.filterNot { it is DeviceListEntry.Disconnect }.isEmpty()) { - Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalAlignment = CenterHorizontally) { - Icon( - imageVector = Icons.Default.BluetoothDisabled, - contentDescription = stringResource(R.string.no_ble_devices), - modifier = Modifier.size(96.dp), - ) - Text( - text = stringResource(R.string.no_ble_devices), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(vertical = 8.dp), - ) + if (isScanning) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator(modifier = Modifier.size(96.dp)) + Text( + text = stringResource(R.string.scanning), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + } else if (btDevices.filterNot { it is DeviceListEntry.Disconnect }.isEmpty()) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.BluetoothDisabled, + contentDescription = stringResource(R.string.no_ble_devices), + modifier = Modifier.size(96.dp), + ) + Text( + text = stringResource(R.string.no_ble_devices), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + } + } else { + // Show a message and a button to grant permissions if not all granted + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + val textToShow = + if (permissionsState.shouldShowRationale) { + stringResource(R.string.permission_missing) + } else { + stringResource(R.string.permission_missing_31) + } + Text(text = textToShow, style = MaterialTheme.typography.bodyMedium) } } + Button( - enabled = !isScanning, + enabled = !isScanning, // Keep disabled during scan modifier = Modifier.fillMaxWidth(), onClick = { - val bluetoothPermissions = context.getBluetoothPermissions() - if (bluetoothPermissions.isEmpty()) { - // If no permissions needed, trigger the scan directly (or via ViewModel) + if (permissionsState.allPermissionsGranted) { scanModel.startScan() } else { - if ( - bluetoothPermissions.any { permission -> - ActivityCompat.shouldShowRequestPermissionRationale(context as Activity, permission) - } - ) { - showBluetoothRationaleDialog() - } else { - requestBluetoothPermission(bluetoothPermissions) - } + permissionsState.launchMultiplePermissionRequest() } }, ) { Icon(imageVector = Icons.Default.Bluetooth, contentDescription = stringResource(R.string.scan)) - Text(stringResource(R.string.scan)) + Text( + if (permissionsState.allPermissionsGranted) { + stringResource(R.string.scan) + } else { + stringResource(R.string.grant_permissions_and_scan) + }, + ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/intro/AppIntroComponents.kt b/app/src/main/java/com/geeksville/mesh/ui/intro/AppIntroComponents.kt deleted file mode 100644 index 3bc5e9335..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/intro/AppIntroComponents.kt +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.geeksville.mesh.ui.intro - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.geeksville.mesh.R -import com.geeksville.mesh.ui.common.components.AutoLinkText -import kotlinx.coroutines.launch - -// Data class for a slide -private data class IntroSlide( - val title: String, - val description: String, - @DrawableRes val imageRes: Int, -) - -@Suppress("LongMethod") -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun AppIntroductionScreen(onDone: () -> Unit) { - val slides = slides() - val pagerState = rememberPagerState(pageCount = { slides.size }) - val scope = rememberCoroutineScope() - - Scaffold( - bottomBar = { - Surface(shadowElevation = 8.dp) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 20.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - if (pagerState.currentPage < slides.size - 1) { - TextButton(onClick = onDone) { - Text(stringResource(id = R.string.app_intro_skip_button)) - } - } else { - TextButton(onClick = { - scope.launch { - pagerState.animateScrollToPage(pagerState.currentPage - 1) - } - }) { - Text(stringResource(id = R.string.app_intro_back_button)) - } - } - - PagerIndicator( - slideCount = slides.size, - currentPage = pagerState.currentPage, - modifier = Modifier.weight(1f) - ) - - if (pagerState.currentPage < slides.size - 1) { - TextButton( - onClick = { - scope.launch { - pagerState.animateScrollToPage(pagerState.currentPage + 1) - } - } - ) { - Text(stringResource(id = R.string.app_intro_next_button)) - } - } else { - Button(onClick = onDone) { - Text(stringResource(id = R.string.app_intro_done_button)) - } - } - } - } - } - ) { innerPadding -> - HorizontalPager( - state = pagerState, - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) { page -> - IntroScreenContent(slides[page]) - } - } -} - -@Composable -private fun slides(): List { - val slides = listOf( - IntroSlide( - title = stringResource(R.string.intro_welcome), - description = stringResource(R.string.intro_welcome_text), - imageRes = R.drawable.app_icon, - ), - IntroSlide( - title = stringResource(R.string.intro_started), - description = stringResource(R.string.intro_started_text), - imageRes = R.drawable.icon_meanings, - ), - IntroSlide( - title = stringResource(R.string.intro_encryption), - description = stringResource(R.string.intro_encryption_text), - imageRes = R.drawable.channel_name_image, - ) - ) - return slides -} - -@Composable -private fun IntroScreenContent(slide: IntroSlide) { - Surface( - modifier = Modifier.fillMaxSize() - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Image( - painter = painterResource(id = slide.imageRes), - contentDescription = slide.title, - modifier = Modifier - .size(200.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop, - ) - Spacer(modifier = Modifier.height(32.dp)) - Text( - text = slide.title, - style = MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(16.dp)) - AutoLinkText( - text = slide.description, - style = MaterialTheme.typography.bodyLarge, - ) - } - } -} - -@Composable -fun PagerIndicator(slideCount: Int, currentPage: Int, modifier: Modifier = Modifier) { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - ) { - repeat(slideCount) { iteration -> - val color = - if (currentPage == iteration) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.2f - ) - } - Box( - modifier = Modifier - .padding(4.dp) - .clip(CircleShape) - .background(color) - .size(12.dp) - ) - } - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/intro/AppIntroductionScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/intro/AppIntroductionScreen.kt new file mode 100644 index 000000000..8fe04634b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/intro/AppIntroductionScreen.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui.intro + +import android.Manifest +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.accompanist.permissions.rememberPermissionState + +/** + * Composable function for the main application introduction screen. This screen guides the user through initial setup + * steps like granting permissions. + * + * @param onDone Callback invoked when the introduction flow is completed. + */ +@OptIn(ExperimentalPermissionsApi::class) +@Suppress("LongMethod") +@Composable +fun AppIntroductionScreen(onDone: () -> Unit) { + val context = LocalContext.current + val navController = rememberNavController() + + val notificationPermissionState: PermissionState? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) + } else { + null + } + + val locationPermissions = + listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) + val locationPermissionState = rememberMultiplePermissionsState(permissions = locationPermissions) + + NavHost(navController = navController, startDestination = IntroRoute.Welcome.route) { + composable(IntroRoute.Welcome.route) { + WelcomeScreen(onGetStarted = { navController.navigate(IntroRoute.Notifications.route) }) + } + composable(IntroRoute.Notifications.route) { + val notificationsAlreadyGranted = notificationPermissionState?.status?.isGranted ?: true + NotificationsScreen( + showNextButton = notificationsAlreadyGranted, + onSkip = { navController.navigate(IntroRoute.Location.route) }, + onConfigure = { + if (notificationsAlreadyGranted) { + navController.navigate(IntroRoute.CriticalAlerts.route) + } else { + // For Android Tiramisu (API 33) and above, this requests POST_NOTIFICATIONS + // For lower versions, notificationPermissionState will be null, and this branch isn't taken. + notificationPermissionState.launchPermissionRequest() + } + }, + ) + } + composable(IntroRoute.CriticalAlerts.route) { + CriticalAlertsScreen( + onSkip = { navController.navigate(IntroRoute.Location.route) }, + onConfigure = { + // Intent to open the specific notification channel settings for "my_alerts" + // This allows the user to enable critical alerts if they were initially denied + // or to adjust settings for notifications that can bypass Do Not Disturb. + val intent = + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + putExtra(Settings.EXTRA_CHANNEL_ID, "my_alerts") + } + context.startActivity(intent) + navController.navigate(IntroRoute.Location.route) + }, + ) + } + composable(IntroRoute.Location.route) { + val locationAlreadyGranted = locationPermissionState.allPermissionsGranted + LocationScreen( + showNextButton = locationAlreadyGranted, + onSkip = onDone, // Callback to signify completion of the intro flow + onConfigure = { + if (locationAlreadyGranted) { + onDone() // Permissions already granted, proceed to finish + } else { + locationPermissionState.launchMultiplePermissionRequest() + } + }, + ) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/intro/CriticalAlertsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/intro/CriticalAlertsScreen.kt new file mode 100644 index 000000000..58143c51f --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/intro/CriticalAlertsScreen.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui.intro + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R + +/** + * Screen for explaining and guiding the user to configure critical alert settings. This screen is part of the app + * introduction flow and appears after the general notification permission screen. + * + * @param onSkip Callback invoked if the user chooses to skip configuring critical alerts. + * @param onConfigure Callback invoked when the user proceeds to configure critical alerts. + */ +@Composable +internal fun CriticalAlertsScreen(onSkip: () -> Unit, onConfigure: () -> Unit) { + Scaffold( + bottomBar = { + IntroBottomBar( + onSkip = onSkip, + onConfigure = onConfigure, + configureButtonText = stringResource(id = R.string.configure_critical_alerts), + skipButtonText = stringResource(id = R.string.skip), + ) + }, + ) { innerPadding -> + Column( + modifier = + Modifier.fillMaxSize().padding(innerPadding).padding(16.dp).verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.critical_alerts), + style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.critical_alerts_dnd_request_text), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/intro/FeatureUIData.kt b/app/src/main/java/com/geeksville/mesh/ui/intro/FeatureUIData.kt new file mode 100644 index 000000000..089be7087 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/intro/FeatureUIData.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui.intro + +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * Data class representing the UI elements for a feature row in the app introduction. + * + * @param icon The vector asset for the feature icon. + * @param titleRes Optional string resource ID for the feature title. + * @param subtitleRes String resource ID for the feature subtitle. + */ +internal data class FeatureUIData( + val icon: ImageVector, + @StringRes val titleRes: Int? = null, + @StringRes val subtitleRes: Int, +) diff --git a/app/src/main/java/com/geeksville/mesh/ui/intro/IntroBottomBar.kt b/app/src/main/java/com/geeksville/mesh/ui/intro/IntroBottomBar.kt new file mode 100644 index 000000000..0e0f8b237 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/intro/IntroBottomBar.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui.intro + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * A common bottom bar used across app introduction screens. Provides consistent "Skip" and "Configure" (or "Next") + * buttons. + * + * @param onSkip Callback for the skip action. + * @param onConfigure Callback for the main configure/next action. + * @param skipButtonText Text for the skip button. + * @param configureButtonText Text for the configure/next button. + * @param showSkipButton Whether to display the skip button. Defaults to true. + */ +@Composable +internal fun IntroBottomBar( + onSkip: () -> Unit, + onConfigure: () -> Unit, + skipButtonText: String, + configureButtonText: String, + showSkipButton: Boolean = true, +) { + Surface(shadowElevation = 8.dp) { + // Use Surface for elevation as per Material 3 guidelines + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (showSkipButton) Arrangement.SpaceBetween else Arrangement.End, + ) { + if (showSkipButton) { + Button(onClick = onSkip) { Text(skipButtonText) } + } + Button(onClick = onConfigure) { Text(configureButtonText) } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/intro/IntroRoute.kt b/app/src/main/java/com/geeksville/mesh/ui/intro/IntroRoute.kt new file mode 100644 index 000000000..4b2cc11ad --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/intro/IntroRoute.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui.intro + +/** Sealed class defining type-safe navigation routes for the app introduction flow. */ +sealed class IntroRoute(val route: String) { + object Welcome : IntroRoute("welcome") + + object Notifications : IntroRoute("notifications") + + object Location : IntroRoute("location") + + object CriticalAlerts : IntroRoute("critical_alerts") +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/intro/IntroUiHelpers.kt b/app/src/main/java/com/geeksville/mesh/ui/intro/IntroUiHelpers.kt new file mode 100644 index 000000000..f5d729748 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/intro/IntroUiHelpers.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui.intro + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp + +/** Tag used for identifying clickable annotations in text, specifically for linking to settings. */ +internal const val SETTINGS_TAG = "settings_link_tag" + +/** + * Displays a row for a feature, including an icon, an optional title, and a subtitle. + * + * @param feature The [FeatureUIData] containing information for the row. + */ +@Composable +internal fun FeatureRow(feature: FeatureUIData) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Icon( + imageVector = feature.icon, + contentDescription = + feature.titleRes?.let { stringResource(id = it) } ?: stringResource(id = feature.subtitleRes), + modifier = Modifier.padding(end = 16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column { + feature.titleRes?.let { titleRes -> + Text( + text = stringResource(id = titleRes), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + ) + } + Text( + text = stringResource(id = feature.subtitleRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Creates an [AnnotatedString] with a clickable portion. + * + * @param fullTextRes String resource for the entire text. + * @param linkTextRes String resource for the portion of text that should be clickable. + * @param tag A tag to identify the annotation. + * @return An [AnnotatedString] with the specified portion styled and annotated. + */ +@Composable +internal fun Context.createClickableAnnotatedString( + @StringRes fullTextRes: Int, + @StringRes linkTextRes: Int, + tag: String, +): AnnotatedString { + val fullText = stringResource(id = fullTextRes) + val linkText = stringResource(id = linkTextRes) + val startIndex = fullText.indexOf(linkText) + + return buildAnnotatedString { + append(fullText) + if (startIndex != -1) { + val endIndex = startIndex + linkText.length + addStyle( + style = SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline), + start = startIndex, + end = endIndex, + ) + addStringAnnotation(tag = tag, annotation = linkText, start = startIndex, end = endIndex) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/intro/LocationScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/intro/LocationScreen.kt new file mode 100644 index 000000000..97545a80f --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/intro/LocationScreen.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui.intro + +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material.icons.outlined.Router +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.geeksville.mesh.R + +/** + * Screen for configuring location permissions during the app introduction. It explains why location permissions are + * needed and provides options to grant them or skip. + * + * @param showNextButton Indicates whether to show a "Next" button (if permissions are already granted) or a "Configure" + * button. + * @param onSkip Callback invoked if the user chooses to skip location permission setup. + * @param onConfigure Callback invoked when the user proceeds to configure or grant permissions. + */ +@Composable +internal fun LocationScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfigure: () -> Unit) { + val context = LocalContext.current + val annotatedString = + context.createClickableAnnotatedString( + fullTextRes = R.string.phone_location_description, + linkTextRes = R.string.settings, + tag = SETTINGS_TAG, + ) + + val features = remember { + listOf( + FeatureUIData( + icon = Icons.Outlined.LocationOn, + titleRes = R.string.share_location, + subtitleRes = R.string.share_location_description, + ), + FeatureUIData( + icon = Icons.Outlined.Router, + titleRes = R.string.distance_measurements, + subtitleRes = R.string.distance_measurements_description, + ), + FeatureUIData( + icon = Icons.Outlined.Router, // Consider a different icon if appropriate + titleRes = R.string.distance_filters, + subtitleRes = R.string.distance_filters_description, + ), + FeatureUIData( + icon = Icons.Outlined.LocationOn, // Consider a different icon if appropriate + titleRes = R.string.mesh_map_location, + subtitleRes = R.string.mesh_map_location_description, + ), + ) + } + + PermissionScreenLayout( + headlineRes = R.string.phone_location, + annotatedDescription = annotatedString, + features = features, + onSkip = onSkip, + onConfigure = onConfigure, + configureButtonTextRes = if (showNextButton) R.string.next else R.string.configure_location_permissions, + onAnnotationClick = { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = Uri.fromParts("package", context.packageName, null) + context.startActivity(intent) + }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/intro/NotificationsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/intro/NotificationsScreen.kt new file mode 100644 index 000000000..481990f8c --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/intro/NotificationsScreen.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui.intro + +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.outlined.BatteryAlert +import androidx.compose.material.icons.outlined.Message +import androidx.compose.material.icons.outlined.SpeakerPhone +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R + +/** + * Screen for configuring notification permissions during the app introduction. It explains why notification permissions + * are needed and provides options to grant them or skip. + * + * @param showNextButton Indicates whether to show a "Next" button (if permissions are already granted) or a "Configure" + * button. + * @param onSkip Callback invoked if the user chooses to skip notification permission setup. + * @param onConfigure Callback invoked when the user proceeds to configure or grant permissions. + */ +@Composable +internal fun NotificationsScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfigure: () -> Unit) { + val context = LocalContext.current + val annotatedString = + context.createClickableAnnotatedString( + fullTextRes = R.string.notification_permissions_description, + linkTextRes = R.string.settings, + tag = SETTINGS_TAG, + ) + + val features = remember { + listOf( + FeatureUIData( + icon = Icons.Outlined.Message, + titleRes = R.string.incoming_messages, + subtitleRes = R.string.notifications_for_channel_and_direct_messages, + ), + FeatureUIData( + icon = Icons.Outlined.SpeakerPhone, + titleRes = R.string.new_nodes, + subtitleRes = R.string.notifications_for_newly_discovered_nodes, + ), + FeatureUIData( + icon = Icons.Outlined.BatteryAlert, + titleRes = R.string.low_battery, + subtitleRes = R.string.notifications_for_low_battery_alerts, + ), + ) + } + + PermissionScreenLayout( + headlineRes = R.string.app_notifications, + annotatedDescription = annotatedString, + features = features, + additionalContent = { + Text( + text = stringResource(R.string.critical_alerts), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + FeatureRow( + feature = + FeatureUIData(icon = Icons.Filled.Notifications, subtitleRes = R.string.critical_alerts_description), + ) + }, + onSkip = onSkip, + onConfigure = onConfigure, + configureButtonTextRes = if (showNextButton) R.string.next else R.string.configure_notification_permissions, + onAnnotationClick = { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = Uri.fromParts("package", context.packageName, null) + context.startActivity(intent) + }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/intro/PermissionScreenLayout.kt b/app/src/main/java/com/geeksville/mesh/ui/intro/PermissionScreenLayout.kt new file mode 100644 index 000000000..e0b6b61aa --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/intro/PermissionScreenLayout.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui.intro + +import androidx.annotation.StringRes +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * A generic layout for screens within the app introduction flow. It typically presents a headline, a descriptive text + * (potentially with clickable annotations), a list of features, and standard navigation buttons. + * + * @param headlineRes String resource for the main headline of the screen. + * @param annotatedDescription The [AnnotatedString] for the main descriptive text. + * @param features A list of [FeatureUIData] to be displayed using [FeatureRow]. + * @param additionalContent Optional composable lambda for adding custom content below the features. + * @param onSkip Callback for the skip action. + * @param onConfigure Callback for the main configure/next action. + * @param configureButtonTextRes String resource for the main action button. + * @param onAnnotationClick Callback invoked when a tagged annotation within [annotatedDescription] is clicked. + */ +@Composable +internal fun PermissionScreenLayout( + @StringRes headlineRes: Int, + annotatedDescription: AnnotatedString, + features: List, + additionalContent: (@Composable () -> Unit)? = null, + onSkip: () -> Unit, + onConfigure: () -> Unit, + @StringRes configureButtonTextRes: Int, + onAnnotationClick: (String) -> Unit, +) { + var textLayoutResult by remember { mutableStateOf(null) } + + val pressIndicator = + Modifier.pointerInput(Unit) { + detectTapGestures { offset -> + textLayoutResult?.let { layoutResult -> + val position = layoutResult.getOffsetForPosition(offset) + annotatedDescription.getStringAnnotations( + SETTINGS_TAG, + position, + position, + ).firstOrNull()?.let { annotation -> + onAnnotationClick(annotation.item) + } + } + } + } + + Scaffold( + bottomBar = { + IntroBottomBar( + onSkip = onSkip, + onConfigure = onConfigure, + configureButtonText = stringResource(id = configureButtonTextRes), + skipButtonText = stringResource(id = com.geeksville.mesh.R.string.skip), + ) + }, + ) { innerPadding -> + Column( + modifier = + Modifier.fillMaxSize().padding(innerPadding).padding(16.dp).verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(headlineRes), + style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = annotatedDescription, + style = + MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ), + modifier = Modifier.padding(horizontal = 16.dp).then(pressIndicator), + onTextLayout = { textLayoutResult = it }, + ) + Spacer(modifier = Modifier.height(16.dp)) + features.forEach { feature -> + FeatureRow(feature = feature) + Spacer(modifier = Modifier.height(16.dp)) + } + additionalContent?.invoke() + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/intro/WelcomeScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/intro/WelcomeScreen.kt new file mode 100644 index 000000000..6cbda246b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/intro/WelcomeScreen.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui.intro + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Hub +import androidx.compose.material.icons.outlined.NearMe +import androidx.compose.material.icons.outlined.SettingsInputAntenna +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R + +/** + * The initial welcome screen for the app introduction flow. It displays a brief overview of the app's key features. + * + * @param onGetStarted Callback invoked when the user proceeds from the welcome screen. + */ +@Composable +internal fun WelcomeScreen(onGetStarted: () -> Unit) { + val features = remember { + listOf( + FeatureUIData( + icon = Icons.Outlined.SettingsInputAntenna, + titleRes = R.string.stay_connected_anywhere, + subtitleRes = R.string.communicate_off_the_grid, + ), + FeatureUIData( + icon = Icons.Outlined.Hub, + titleRes = R.string.create_your_own_networks, + subtitleRes = R.string.easily_set_up_private_mesh_networks, + ), + FeatureUIData( + icon = Icons.Outlined.NearMe, + titleRes = R.string.track_and_share_locations, + subtitleRes = R.string.share_your_location_in_real_time, + ), + ) + } + + Scaffold( + bottomBar = { + IntroBottomBar( + onSkip = {}, // No skip on welcome + onConfigure = onGetStarted, + skipButtonText = "", // Not shown + configureButtonText = stringResource(id = R.string.get_started), + showSkipButton = false, // Explicitly hide skip for welcome + ) + }, + ) { innerPadding -> + Column( + modifier = + Modifier.fillMaxSize().padding(innerPadding).padding(16.dp).verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.intro_welcome), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.meshtastic), + style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(32.dp)) + features.forEach { feature -> + FeatureRow(feature = feature) + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/MapView.kt b/app/src/main/java/com/geeksville/mesh/ui/map/MapView.kt index 7f450a713..afb7e1ac9 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/map/MapView.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/MapView.kt @@ -17,9 +17,8 @@ package com.geeksville.mesh.ui.map +import android.Manifest // Added for Accompanist import android.content.Context -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.content.res.AppCompatResources import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -67,10 +66,8 @@ import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MeshProtos.Waypoint import com.geeksville.mesh.R import com.geeksville.mesh.android.BuildUtils.debug -import com.geeksville.mesh.android.getLocationPermissions import com.geeksville.mesh.android.gpsDisabled import com.geeksville.mesh.android.hasGps -import com.geeksville.mesh.android.hasLocationPermission import com.geeksville.mesh.copy import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.model.Node @@ -89,6 +86,8 @@ import com.geeksville.mesh.util.createLatLongGrid import com.geeksville.mesh.util.formatAgo import com.geeksville.mesh.util.zoomIn import com.geeksville.mesh.waypoint +import com.google.accompanist.permissions.ExperimentalPermissionsApi // Added for Accompanist +import com.google.accompanist.permissions.rememberMultiplePermissionsState // Added for Accompanist import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable import org.osmdroid.config.Configuration @@ -201,6 +200,14 @@ private fun Context.purgeTileSource(onResult: (String) -> Unit) { builder.show() } +/** + * Main composable for displaying the map view, including nodes, waypoints, and user location. It handles user + * interactions for map manipulation, filtering, and offline caching. + * + * @param model The [UIViewModel] providing data and state for the map. + * @param navigateToNodeDetails Callback to navigate to the details screen of a selected node. + */ +@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist @Suppress("CyclomaticComplexMethod", "LongMethod") @Composable fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Unit) { @@ -208,13 +215,11 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un val mapFilterState by model.mapFilterStateFlow.collectAsState() - // UI Elements var cacheEstimate by remember { mutableStateOf("") } var zoomLevelMin by remember { mutableDoubleStateOf(0.0) } var zoomLevelMax by remember { mutableDoubleStateOf(0.0) } - // Map Elements var downloadRegionBoundingBox: BoundingBox? by remember { mutableStateOf(null) } var myLocationOverlay: MyLocationNewOverlay? by remember { mutableStateOf(null) } @@ -230,6 +235,11 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un val hasGps = remember { context.hasGps() } + // Accompanist permissions state for location + val locationPermissionsState = + rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) + var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) } + fun loadOnlineTileSourceBase(): ITileSource { val id = model.mapStyleId debug("mapStyleId from prefs: $id") @@ -279,10 +289,13 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un } } - val requestPermissionAndToggleLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> - if (permissions.entries.all { it.value }) map.toggleMyLocation() + // Effect to toggle MyLocation after permission is granted + LaunchedEffect(locationPermissionsState.allPermissionsGranted) { + if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) { + map.toggleMyLocation() + triggerLocationToggleAfterPermission = false } + } val nodes by model.filteredNodeList.collectAsStateWithLifecycle() val waypoints by model.waypoints.collectAsStateWithLifecycle(emptyMap()) @@ -294,9 +307,9 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un val ourNode = model.ourNodeInfo.value val gpsFormat = model.config.display.gpsFormat.number val displayUnits = model.config.display.units - val mapFilterState = model.mapFilterStateFlow.value // Access mapFilterState directly + val mapFilterStateValue = model.mapFilterStateFlow.value // Access mapFilterState directly return nodesWithPosition.mapNotNull { node -> - if (mapFilterState.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) { + if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) { return@mapNotNull null } @@ -320,7 +333,7 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un position = nodePosition icon = markerIcon setNodeColors(node.colors) - if (!mapFilterState.showPrecisionCircle) { + if (!mapFilterStateValue.showPrecisionCircle) { setPrecisionBits(0) } else { setPrecisionBits(p.precisionBits) @@ -388,7 +401,7 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un val dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) return waypoints.mapNotNull { waypoint -> val pt = waypoint.data.waypoint ?: return@mapNotNull null - if (!mapFilterState.showWaypoints) return@mapNotNull null + if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState val lock = if (pt.lockedTo != 0) "\uD83D\uDD12" else "" val time = dateFormat.format(waypoint.received_time) val label = pt.name + " " + formatAgo((waypoint.received_time / 1000).toInt()) @@ -418,7 +431,7 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)" snippet = "[$time] ${pt.description} " + stringResource(R.string.expires) + ": $expireTimeStr" position = GeoPoint(pt.latitudeI * 1e-7, pt.longitudeI * 1e-7) - setVisible(false) + setVisible(false) // This seems to be always false, was this intended? setOnLongClickListener { showMarkerLongPressDialog(pt.id) true @@ -430,7 +443,7 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un LaunchedEffect(showCurrentCacheInfo) { if (!showCurrentCacheInfo) return@LaunchedEffect model.showSnackbar(R.string.calculating) - val cacheManager = CacheManager(map) // Make sure CacheManager has latest from map + val cacheManager = CacheManager(map) val cacheCapacity = cacheManager.cacheCapacity() val currentCacheUsage = cacheManager.currentCacheUsage() @@ -483,7 +496,7 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un overlays.add(nodeClusterer) } - addCopyright() // Copyright is required for certain map sources + addCopyright() addScaleBarOverlay(density) createLatLongGrid(false) @@ -492,10 +505,9 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un with(map) { UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values), nodeClusterer) } - /** Creates Box overlay showing what area can be downloaded */ fun MapView.generateBoxOverlay() { overlays.removeAll { it is Polygon } - val zoomFactor = 1.3 // zoom difference between view and download area polygon + val zoomFactor = 1.3 zoomLevelMin = minOf(zoomLevelDouble, zoomLevelMax) downloadRegionBoundingBox = boundingBox.zoomIn(zoomFactor) val polygon = @@ -528,12 +540,10 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un val outputName = buildString { append(Configuration.getInstance().osmdroidBasePath.absolutePath) append(File.separator) - append("mainFile.sqlite") // TODO: Accept filename input param from user + append("mainFile.sqlite") } val writer = SqliteArchiveTileWriter(outputName) - // Make sure cacheManager has latest from map val cacheManager = CacheManager(map, writer) - // this triggers the download cacheManager.downloadAreaAsync( context, boundingBox, @@ -589,7 +599,6 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un map.generateBoxOverlay() dialog.dismiss() } - 2 -> purgeTileSource { model.showSnackbar(it) } else -> dialog.dismiss() } @@ -606,12 +615,12 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un AndroidView( factory = { map.apply { - setDestroyMode(false) // keeps map instance alive when in the background + setDestroyMode(false) addMapListener(boxOverlayListener) } }, modifier = Modifier.fillMaxSize(), - update = { map -> map.drawOverlays() }, + update = { mapView -> mapView.drawOverlays() }, // Renamed map to mapView to avoid conflict ) if (downloadRegionBoundingBox != null) { CacheLayout( @@ -645,7 +654,6 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un onDismissRequest = { mapFilterExpanded = false }, modifier = Modifier.background(MaterialTheme.colorScheme.surface), ) { - // Only Favorites toggle DropdownMenuItem( text = { Row( @@ -714,7 +722,7 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un ) Checkbox( checked = mapFilterState.showPrecisionCircle, - onCheckedChange = { enabled -> model.toggleShowPrecisionCircleOnMap() }, + onCheckedChange = { model.toggleShowPrecisionCircleOnMap() }, modifier = Modifier.padding(start = 8.dp), ) } @@ -733,10 +741,11 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un }, contentDescription = stringResource(R.string.toggle_my_position), ) { - if (context.hasLocationPermission()) { + if (locationPermissionsState.allPermissionsGranted) { map.toggleMyLocation() } else { - requestPermissionAndToggleLauncher.launch(context.getLocationPermissions()) + triggerLocationToggleAfterPermission = true + locationPermissionsState.launchMultiplePermissionRequest() } } } @@ -747,7 +756,7 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un if (showEditWaypointDialog != null) { EditWaypointDialog( - waypoint = showEditWaypointDialog ?: return, + waypoint = showEditWaypointDialog ?: return, // Safe call onSendClicked = { waypoint -> debug("User clicked send waypoint ${waypoint.id}") showEditWaypointDialog = null diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt index e2dca8861..a786c1d07 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt @@ -17,11 +17,11 @@ package com.geeksville.mesh.ui.sharing +import android.Manifest import android.content.ClipData import android.net.Uri import android.os.RemoteException import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -71,7 +71,6 @@ import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -92,8 +91,6 @@ import com.geeksville.mesh.analytics.DataPair import com.geeksville.mesh.android.BuildUtils.debug import com.geeksville.mesh.android.BuildUtils.errormsg import com.geeksville.mesh.android.GeeksvilleApplication -import com.geeksville.mesh.android.getCameraPermissions -import com.geeksville.mesh.android.hasCameraPermission import com.geeksville.mesh.channelSet import com.geeksville.mesh.copy import com.geeksville.mesh.model.Channel @@ -105,23 +102,30 @@ import com.geeksville.mesh.navigation.ConfigRoute import com.geeksville.mesh.navigation.Route import com.geeksville.mesh.navigation.getNavRouteFrom import com.geeksville.mesh.service.MeshService -import com.geeksville.mesh.ui.radioconfig.RadioConfigViewModel import com.geeksville.mesh.ui.common.components.AdaptiveTwoPane import com.geeksville.mesh.ui.common.components.PreferenceFooter +import com.geeksville.mesh.ui.radioconfig.RadioConfigViewModel import com.geeksville.mesh.ui.radioconfig.components.ChannelSelection import com.geeksville.mesh.ui.radioconfig.components.PacketResponseStateDialog +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions import kotlinx.coroutines.launch +/** + * Composable screen for managing and sharing Meshtastic channels. Allows users to view, edit, and share channel + * configurations via QR codes or URLs. + */ +@OptIn(ExperimentalPermissionsApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun ChannelScreen( viewModel: UIViewModel = hiltViewModel(), radioConfigViewModel: RadioConfigViewModel = hiltViewModel(), - onNavigate: (Route) -> Unit + onNavigate: (Route) -> Unit, ) { - val context = LocalContext.current val focusManager = LocalFocusManager.current val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() @@ -131,12 +135,9 @@ fun ChannelScreen( val channels by viewModel.channels.collectAsStateWithLifecycle() var channelSet by remember(channels) { mutableStateOf(channels) } - val modemPresetName by remember(channels) { - mutableStateOf(Channel(loraConfig = channels.loraConfig).name) - } + val modemPresetName by remember(channels) { mutableStateOf(Channel(loraConfig = channels.loraConfig).name) } var showResetDialog by remember { mutableStateOf(false) } - var showScanDialog by remember { mutableStateOf(false) } /* Animate waiting for the configurations */ var isWaiting by remember { mutableStateOf(false) } @@ -158,24 +159,24 @@ fun ChannelScreen( } /* Holds selections made by the user for QR generation. */ - val channelSelections = rememberSaveable( - saver = listSaver( - save = { it.toList() }, - restore = { it.toMutableStateList() } - ) - ) { mutableStateListOf(elements = Array(size = 8, init = { true })) } - - val selectedChannelSet = channelSet.copy { - val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true } - settings.clear() - settings.addAll(result) - } - - val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> - if (result.contents != null) { - viewModel.requestChannelUrl(result.contents.toUri()) + val channelSelections = + rememberSaveable(saver = listSaver(save = { it.toList() }, restore = { it.toMutableStateList() })) { + mutableStateListOf(elements = Array(size = 8, init = { true })) + } + + val selectedChannelSet = + channelSet.copy { + val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true } + settings.clear() + settings.addAll(result) + } + + val barcodeLauncher = + rememberLauncherForActivityResult(ScanContract()) { result -> + if (result.contents != null) { + viewModel.requestChannelUrl(result.contents.toUri()) + } } - } fun zxingScan() { debug("Starting zxing QR code scanner") @@ -187,30 +188,15 @@ fun ChannelScreen( barcodeLauncher.launch(zxingScan) } - val requestPermissionAndScanLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> - if (permissions.entries.all { it.value }) zxingScan() - } + val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) - if (showScanDialog) { - AlertDialog( - onDismissRequest = { - debug("Camera permission denied") - showScanDialog = false - }, - title = { Text(text = stringResource(id = R.string.camera_required)) }, - text = { Text(text = stringResource(id = R.string.why_camera_required)) }, - confirmButton = { - TextButton(onClick = { requestPermissionAndScanLauncher.launch(context.getCameraPermissions()) }) { - Text(text = stringResource(id = R.string.accept)) - } - }, - dismissButton = { - TextButton(onClick = { debug("Camera permission denied") }) { - Text(text = stringResource(id = R.string.cancel)) - } - } - ) + LaunchedEffect(cameraPermissionState.status) { + if (cameraPermissionState.status.isGranted) { + // If permission was granted as a result of a request, and not initially, + // we might want to trigger the scan. However, simple auto-triggering on grant + // might not always be desired UX. For now, rely on user re-click if needed. + // If auto-scan is desired after grant: add a flag to track if request was made. + } } // Send new channel settings to the device @@ -229,10 +215,7 @@ fun ChannelScreen( } } - fun installSettings( - newChannel: ChannelProtos.ChannelSettings, - newLoRaConfig: ConfigProtos.Config.LoRaConfig - ) { + fun installSettings(newChannel: ChannelProtos.ChannelSettings, newLoRaConfig: ConfigProtos.Config.LoRaConfig) { val newSet = channelSet { settings.add(newChannel) loraConfig = newLoRaConfig @@ -249,32 +232,37 @@ fun ChannelScreen( title = { Text(text = stringResource(id = R.string.reset_to_defaults)) }, text = { Text(text = stringResource(id = R.string.are_you_sure_change_default)) }, confirmButton = { - TextButton(onClick = { - debug("Switching back to default channel") - installSettings( - Channel.default.settings, - Channel.default.loraConfig.copy { - region = viewModel.region - txEnabled = viewModel.txEnabled - } - ) - showResetDialog = false - }) { Text(text = stringResource(id = R.string.apply)) } + TextButton( + onClick = { + debug("Switching back to default channel") + installSettings( + Channel.default.settings, + Channel.default.loraConfig.copy { + region = viewModel.region + txEnabled = viewModel.txEnabled + }, + ) + showResetDialog = false + }, + ) { + Text(text = stringResource(id = R.string.apply)) + } }, dismissButton = { - TextButton(onClick = { - channelSet = channels // throw away any edits - showResetDialog = false - }) { Text(text = stringResource(id = R.string.cancel)) } - } + TextButton( + onClick = { + channelSet = channels // throw away any edits + showResetDialog = false + }, + ) { + Text(text = stringResource(id = R.string.cancel)) + } + }, ) } val listState = rememberLazyListState() - LazyColumn( - state = listState, - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp), - ) { + LazyColumn(state = listState, contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp)) { item { ChannelListView( enabled = enabled, @@ -284,22 +272,22 @@ fun ChannelScreen( onClick = { isWaiting = true radioConfigViewModel.setResponseStateLoading(ConfigRoute.CHANNELS) - } + }, ) EditChannelUrl( enabled = enabled, channelUrl = selectedChannelSet.getChannelUrl(), - onConfirm = viewModel::requestChannelUrl + onConfirm = viewModel::requestChannelUrl, ) } item { - ModemPresetInfo( - modemPresetName = modemPresetName, - onClick = { - isWaiting = true - radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA) - } - ) + ModemPresetInfo( + modemPresetName = modemPresetName, + onClick = { + isWaiting = true + radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA) + }, + ) } item { PreferenceFooter( @@ -312,8 +300,12 @@ fun ChannelScreen( positiveText = R.string.scan, onPositiveClicked = { focusManager.clearFocus() - if (context.hasCameraPermission()) zxingScan() else showScanDialog = true - } + if (cameraPermissionState.status.isGranted) { + zxingScan() + } else { + cameraPermissionState.launchPermissionRequest() + } + }, ) } } @@ -321,12 +313,7 @@ fun ChannelScreen( @Suppress("LongMethod") @Composable -private fun EditChannelUrl( - enabled: Boolean, - channelUrl: Uri, - modifier: Modifier = Modifier, - onConfirm: (Uri) -> Unit -) { +private fun EditChannelUrl(enabled: Boolean, channelUrl: Uri, modifier: Modifier = Modifier, onConfirm: (Uri) -> Unit) { val focusManager = LocalFocusManager.current val clipboardManager = LocalClipboard.current val coroutineScope = rememberCoroutineScope() @@ -344,10 +331,12 @@ private fun EditChannelUrl( OutlinedTextField( value = valueState.toString(), onValueChange = { - isError = runCatching { - valueState = it.toUri() - valueState.toChannelSet() - }.isFailure + isError = + runCatching { + valueState = it.toUri() + valueState.toChannelSet() + } + .isFailure }, modifier = modifier.fillMaxWidth(), enabled = enabled, @@ -357,77 +346,68 @@ private fun EditChannelUrl( trailingIcon = { val label = stringResource(R.string.url) val isUrlEqual = valueState == channelUrl - IconButton(onClick = { - when { - isError -> { - isError = false - valueState = channelUrl - } + IconButton( + onClick = { + when { + isError -> { + isError = false + valueState = channelUrl + } - !isUrlEqual -> { - onConfirm(valueState) - valueState = channelUrl - } + !isUrlEqual -> { + onConfirm(valueState) + valueState = channelUrl + } - else -> { - // track how many times users share channels - GeeksvilleApplication.analytics.track( - "share", DataPair("content_type", "channel") - ) - coroutineScope.launch { - clipboardManager.setClipEntry( - ClipEntry( - ClipData.newPlainText( - label, - valueState.toString() - ) + else -> { + // track how many times users share channels + GeeksvilleApplication.analytics.track("share", DataPair("content_type", "channel")) + coroutineScope.launch { + clipboardManager.setClipEntry( + ClipEntry(ClipData.newPlainText(label, valueState.toString())), ) - ) + } } } - } - }) { + }, + ) { Icon( - imageVector = when { + imageVector = + when { isError -> Icons.TwoTone.Close !isUrlEqual -> Icons.TwoTone.Check else -> Icons.TwoTone.ContentCopy }, - contentDescription = when { + contentDescription = + when { isError -> stringResource(R.string.copy) !isUrlEqual -> stringResource(R.string.send) else -> stringResource(R.string.copy) }, - tint = if (isError) { + tint = + if (isError) { MaterialTheme.colorScheme.error } else { LocalContentColor.current - } + }, ) } }, maxLines = 1, singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done - ), + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), ) } @Composable -private fun QrCodeImage( - enabled: Boolean, - channelSet: ChannelSet, - modifier: Modifier = Modifier, -) = Image( - painter = channelSet.qrCode - ?.let { BitmapPainter(it.asImageBitmap()) } - ?: painterResource(id = R.drawable.qrcode), +private fun QrCodeImage(enabled: Boolean, channelSet: ChannelSet, modifier: Modifier = Modifier) = Image( + painter = + channelSet.qrCode?.let { BitmapPainter(it.asImageBitmap()) } ?: painterResource(id = R.drawable.qrcode), contentDescription = stringResource(R.string.qr_code), modifier = modifier, contentScale = ContentScale.Inside, - alpha = if (enabled) 1.0f else 0.7f + alpha = if (enabled) 1.0f else 0.7f, // colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), ) @@ -439,11 +419,12 @@ private fun ChannelListView( channelSelections: SnapshotStateList, onClick: () -> Unit = {}, ) { - val selectedChannelSet = channelSet.copy { - val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true } - settings.clear() - settings.addAll(result) - } + val selectedChannelSet = + channelSet.copy { + val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true } + settings.clear() + settings.addAll(result) + } AdaptiveTwoPane( first = { @@ -461,66 +442,47 @@ private fun ChannelListView( channelSelections[index] = it } }, - channel = channelObj + channel = channelObj, ) } OutlinedButton( onClick = onClick, modifier = Modifier.fillMaxWidth(), enabled = enabled, - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { Text(text = stringResource(R.string.edit)) } + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface), + ) { + Text(text = stringResource(R.string.edit)) + } }, second = { QrCodeImage( enabled = enabled, channelSet = selectedChannelSet, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), ) }, ) } @Composable -private fun ModemPresetInfo( - modemPresetName: String, - onClick: () -> Unit -) { +private fun ModemPresetInfo(modemPresetName: String, onClick: () -> Unit) { Row( - modifier = Modifier - .padding(top = 12.dp) + modifier = + Modifier.padding(top = 12.dp) .fillMaxWidth() .clickable(onClick = onClick) - .border( - 1.dp, - MaterialTheme.colorScheme.onBackground, - RoundedCornerShape(8.dp) - ), - verticalAlignment = Alignment.CenterVertically + .border(1.dp, MaterialTheme.colorScheme.onBackground, RoundedCornerShape(8.dp)), + verticalAlignment = Alignment.CenterVertically, ) { - Column( - modifier = Modifier - .weight(1f) - .padding(16.dp) - ) { - Text( - text = stringResource(R.string.modem_preset), - fontSize = 16.sp, - ) - Text( - text = modemPresetName, - fontSize = 14.sp, - ) + Column(modifier = Modifier.weight(1f).padding(16.dp)) { + Text(text = stringResource(R.string.modem_preset), fontSize = 16.sp) + Text(text = modemPresetName, fontSize = 14.sp) } Spacer(modifier = Modifier.width(16.dp)) Icon( imageVector = Icons.Default.ChevronRight, contentDescription = stringResource(R.string.navigate_into_label), - modifier = Modifier.padding(end = 16.dp) + modifier = Modifier.padding(end = 16.dp), ) } } @@ -528,10 +490,7 @@ private fun ModemPresetInfo( @Preview(showBackground = true) @Composable fun ModemPresetInfoPreview() { - ModemPresetInfo( - modemPresetName = "Long Fast", - onClick = {} - ) + ModemPresetInfo(modemPresetName = "Long Fast", onClick = {}) } @PreviewScreenSizes @@ -539,7 +498,8 @@ fun ModemPresetInfoPreview() { private fun ChannelScreenPreview() { ChannelListView( enabled = true, - channelSet = channelSet { + channelSet = + channelSet { settings.add(Channel.default.settings) loraConfig = Channel.default.loraConfig }, diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/ContactSharing.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/ContactSharing.kt index 7806d4922..6bf9308a5 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/sharing/ContactSharing.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/ContactSharing.kt @@ -17,12 +17,11 @@ package com.geeksville.mesh.ui.sharing -import android.content.pm.PackageManager +import android.Manifest import android.graphics.Bitmap import android.net.Uri import android.util.Base64 import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -36,17 +35,14 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -59,12 +55,14 @@ import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.R import com.geeksville.mesh.android.BuildUtils.debug import com.geeksville.mesh.android.BuildUtils.errormsg -import com.geeksville.mesh.android.getCameraPermissions import com.geeksville.mesh.model.DeviceVersion import com.geeksville.mesh.model.Node import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.ui.common.components.CopyIconButton import com.geeksville.mesh.ui.common.components.SimpleAlertDialog +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState import com.google.protobuf.ByteString import com.google.protobuf.Descriptors import com.google.zxing.BarcodeFormat @@ -75,6 +73,15 @@ import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions import java.net.MalformedURLException +/** + * Composable FloatingActionButton to initiate scanning a QR code for adding a contact. Handles camera permission + * requests using Accompanist Permissions. + * + * @param modifier Modifier for this composable. + * @param model UIViewModel for interacting with application state. + * @param onSharedContactImport Callback invoked when a shared contact is successfully imported. + */ +@OptIn(ExperimentalPermissionsApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun AddContactFAB( @@ -82,23 +89,24 @@ fun AddContactFAB( model: UIViewModel = hiltViewModel(), onSharedContactImport: (AdminProtos.SharedContact) -> Unit = {}, ) { - val context = LocalContext.current val contactToImport: AdminProtos.SharedContact? by model.sharedContactRequested.collectAsStateWithLifecycle(null) - val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> - if (result.contents != null) { - val uri = result.contents.toUri() - val sharedContact = try { - uri.toSharedContact() - } catch (ex: MalformedURLException) { - errormsg("URL was malformed: ${ex.message}") - null - } - if (sharedContact != null) { - model.setSharedContactRequested(sharedContact) + val barcodeLauncher = + rememberLauncherForActivityResult(ScanContract()) { result -> + if (result.contents != null) { + val uri = result.contents.toUri() + val sharedContact = + try { + uri.toSharedContact() + } catch (ex: MalformedURLException) { + errormsg("URL was malformed: ${ex.message}") + null + } + if (sharedContact != null) { + model.setSharedContactRequested(sharedContact) + } } } - } if (contactToImport != null) { val nodeNum = contactToImport?.nodeNum @@ -109,39 +117,27 @@ fun AddContactFAB( text = { Column { if (node != null) { - Text( - text = stringResource( - R.string.import_known_shared_contact_text - ) - ) + Text(text = stringResource(R.string.import_known_shared_contact_text)) if (node.user.publicKey.size() > 0 && node.user.publicKey != contactToImport?.user?.publicKey) { Text( - text = stringResource( - R.string.public_key_changed - ), - color = MaterialTheme.colorScheme.error + text = stringResource(R.string.public_key_changed), + color = MaterialTheme.colorScheme.error, ) } HorizontalDivider() - Text( - text = compareUsers(node.user, contactToImport!!.user) - ) + Text(text = compareUsers(node.user, contactToImport!!.user)) } else { - Text( - text = userFieldsToString(contactToImport!!.user) - ) + Text(text = userFieldsToString(contactToImport!!.user)) } } }, dismissText = stringResource(R.string.cancel), - onDismiss = { - model.setSharedContactRequested(null) - }, + onDismiss = { model.setSharedContactRequested(null) }, confirmText = stringResource(R.string.import_label), onConfirm = { onSharedContactImport(contactToImport!!) model.setSharedContactRequested(null) - } + }, ) } @@ -155,181 +151,136 @@ fun AddContactFAB( barcodeLauncher.launch(zxingScan) } - val requestPermissionAndScanLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> - if (permissions.entries.all { it.value }) zxingScan() - } + val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) - var showPermissionRationale by remember { mutableStateOf(false) } - if (showPermissionRationale) { - SimpleAlertDialog( - title = R.string.camera_required, - text = R.string.why_camera_required, - onDismiss = { - debug("Camera permission denied") - showPermissionRationale = false - }, - onConfirm = { - requestPermissionAndScanLauncher.launch(context.getCameraPermissions()) - showPermissionRationale = false - } - ) - } - fun requestPermissionAndScan() { - showPermissionRationale = true + LaunchedEffect(cameraPermissionState.status) { + if (cameraPermissionState.status.isGranted) { + // If permission was granted as a result of a request, and not initially, + // we might want to trigger the scan. However, simple auto-triggering on grant + // might not always be desired UX. For now, rely on user re-click if needed. + } } FloatingActionButton( onClick = { - if (context.getCameraPermissions().all { - context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED - } - ) { + if (cameraPermissionState.status.isGranted) { zxingScan() } else { - requestPermissionAndScan() + cameraPermissionState.launchPermissionRequest() } }, - modifier = modifier.padding(16.dp) + modifier = modifier.padding(16.dp), ) { - Icon( - imageVector = Icons.TwoTone.QrCodeScanner, - contentDescription = stringResource(R.string.scan_qr_code), - ) + Icon(imageVector = Icons.TwoTone.QrCodeScanner, contentDescription = stringResource(R.string.scan_qr_code)) } } @Composable -private fun QrCodeImage( - uri: Uri, - modifier: Modifier = Modifier, -) = Image( - painter = uri.qrCode - ?.let { BitmapPainter(it.asImageBitmap()) } - ?: painterResource(id = R.drawable.qrcode), +private fun QrCodeImage(uri: Uri, modifier: Modifier = Modifier) = Image( + painter = uri.qrCode?.let { BitmapPainter(it.asImageBitmap()) } ?: painterResource(id = R.drawable.qrcode), contentDescription = stringResource(R.string.qr_code), modifier = modifier, contentScale = ContentScale.Inside, ) @Composable -private fun SharedContact( - contactUri: Uri, -) { +private fun SharedContact(contactUri: Uri) { Column { - QrCodeImage( - uri = contactUri, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = contactUri.toString(), - modifier = Modifier - .weight(1f) - ) - CopyIconButton( - valueToCopy = contactUri.toString(), - modifier = Modifier.padding(start = 8.dp) - ) + QrCodeImage(uri = contactUri, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) + Row(modifier = Modifier.fillMaxWidth().padding(4.dp), verticalAlignment = Alignment.CenterVertically) { + Text(text = contactUri.toString(), modifier = Modifier.weight(1f)) + CopyIconButton(valueToCopy = contactUri.toString(), modifier = Modifier.padding(start = 8.dp)) } } } +/** + * Displays a dialog with the contact's information as a QR code and URI. + * + * @param contact The node representing the contact to share. Null if no contact is selected. + * @param onDismiss Callback invoked when the dialog is dismissed. + */ @Composable -fun SharedContactDialog( - contact: Node?, - onDismiss: () -> Unit, -) { +fun SharedContactDialog(contact: Node?, onDismiss: () -> Unit) { if (contact == null) return - val sharedContact = - AdminProtos.SharedContact.newBuilder().setUser(contact.user).setNodeNum(contact.num).build() + val sharedContact = AdminProtos.SharedContact.newBuilder().setUser(contact.user).setNodeNum(contact.num).build() val uri = sharedContact.getSharedContactUrl() SimpleAlertDialog( title = R.string.share_contact, text = { Column { Text(contact.user.longName) - SharedContact( - contactUri = uri, - ) + SharedContact(contactUri = uri) } }, - onDismiss = onDismiss + onDismiss = onDismiss, ) } @Preview @Composable private fun ShareContactPreview() { - SharedContact( - contactUri = "https://example.com".toUri(), - ) + SharedContact(contactUri = "https://example.com".toUri()) } +/** Bitmap representation of the Uri as a QR code, or null if generation fails. */ val Uri.qrCode: Bitmap? - get() = try { - val multiFormatWriter = MultiFormatWriter() - val bitMatrix = - multiFormatWriter.encode( - this.toString(), - BarcodeFormat.QR_CODE, - BARCODE_PIXEL_SIZE, - BARCODE_PIXEL_SIZE - ) - val barcodeEncoder = BarcodeEncoder() - barcodeEncoder.createBitmap(bitMatrix) - } catch (ex: WriterException) { - errormsg("URL was too complex to render as barcode: ${ex.message}") - null - } + get() = + try { + val multiFormatWriter = MultiFormatWriter() + val bitMatrix = + multiFormatWriter.encode(this.toString(), BarcodeFormat.QR_CODE, BARCODE_PIXEL_SIZE, BARCODE_PIXEL_SIZE) + val barcodeEncoder = BarcodeEncoder() + barcodeEncoder.createBitmap(bitMatrix) + } catch (ex: WriterException) { + errormsg("URL was too complex to render as barcode: ${ex.message}") + null + } private const val REQUIRED_MIN_FIRMWARE = "2.6.8" private const val BARCODE_PIXEL_SIZE = 960 private const val MESHTASTIC_HOST = "meshtastic.org" private const val CONTACT_SHARE_PATH = "/v/" + +/** Prefix for Meshtastic contact sharing URLs. */ internal const val URL_PREFIX = "https://$MESHTASTIC_HOST$CONTACT_SHARE_PATH#" private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING private const val CAMERA_ID = 0 -fun DeviceVersion.supportsQrCodeSharing(): Boolean = - this >= DeviceVersion(REQUIRED_MIN_FIRMWARE) +/** Checks if the device firmware version supports QR code sharing. */ +fun DeviceVersion.supportsQrCodeSharing(): Boolean = this >= DeviceVersion(REQUIRED_MIN_FIRMWARE) +/** + * Converts a URI to a [AdminProtos.SharedContact]. + * + * @throws MalformedURLException if the URI is not a valid Meshtastic contact sharing URL. + */ @Suppress("MagicNumber") @Throws(MalformedURLException::class) fun Uri.toSharedContact(): AdminProtos.SharedContact { - if (fragment.isNullOrBlank() || - !host.equals(MESHTASTIC_HOST, true) || - !path.equals(CONTACT_SHARE_PATH, true) - ) { + if (fragment.isNullOrBlank() || !host.equals(MESHTASTIC_HOST, true) || !path.equals(CONTACT_SHARE_PATH, true)) { throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}") } - val url = AdminProtos.SharedContact.parseFrom(Base64.decode(fragment!!, BASE64FLAGS)) - return url.toBuilder().build() - } + val url = AdminProtos.SharedContact.parseFrom(Base64.decode(fragment!!, BASE64FLAGS)) + return url.toBuilder().build() +} +/** Converts a [AdminProtos.SharedContact] to its corresponding URI representation. */ fun AdminProtos.SharedContact.getSharedContactUrl(): Uri { val bytes = this.toByteArray() ?: ByteArray(0) val enc = Base64.encodeToString(bytes, BASE64FLAGS) return "$URL_PREFIX$enc".toUri() } +/** Compares two [MeshProtos.User] objects and returns a string detailing the differences. */ fun compareUsers(oldUser: MeshProtos.User, newUser: MeshProtos.User): String { val changes = mutableListOf() // Iterate over all fields in the User message descriptor for (fieldDescriptor: Descriptors.FieldDescriptor in MeshProtos.User.getDescriptor().fields) { val fieldName = fieldDescriptor.name - val oldValue = - if (oldUser.hasField(fieldDescriptor)) oldUser.getField(fieldDescriptor) else null - val newValue = - if (newUser.hasField(fieldDescriptor)) newUser.getField(fieldDescriptor) else null + val oldValue = if (oldUser.hasField(fieldDescriptor)) oldUser.getField(fieldDescriptor) else null + val newValue = if (newUser.hasField(fieldDescriptor)) newUser.getField(fieldDescriptor) else null if (oldValue != newValue) { val oldValueString = valueToString(oldValue, fieldDescriptor) @@ -345,6 +296,7 @@ fun compareUsers(oldUser: MeshProtos.User, newUser: MeshProtos.User): String { } } +/** Converts a [MeshProtos.User] object to a string representation of its fields and values. */ fun userFieldsToString(user: MeshProtos.User): String { val fieldLines = mutableListOf() @@ -352,21 +304,18 @@ fun userFieldsToString(user: MeshProtos.User): String { val fieldName = fieldDescriptor.name if (user.hasField(fieldDescriptor)) { val value = user.getField(fieldDescriptor) - val valueString = - valueToString(value, fieldDescriptor) // Using the helper from previous example + val valueString = valueToString(value, fieldDescriptor) // Using the helper from previous example fieldLines.add("$fieldName: $valueString") } else if (fieldDescriptor.isRepeated || fieldDescriptor.hasDefaultValue() || fieldDescriptor.isOptional) { val defaultValue = fieldDescriptor.defaultValue - val valueString = if (fieldDescriptor.isRepeated) { - "[]" // Empty list - } else if (user.hasField(fieldDescriptor)) { - valueToString( - user.getField(fieldDescriptor), - fieldDescriptor - ) - } else { - valueToString(defaultValue, fieldDescriptor) - } + val valueString = + if (fieldDescriptor.isRepeated) { + "[]" // Empty list + } else if (user.hasField(fieldDescriptor)) { + valueToString(user.getField(fieldDescriptor), fieldDescriptor) + } else { + valueToString(defaultValue, fieldDescriptor) + } fieldLines.add("$fieldName: $valueString") } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3070abac8..17bf3b862 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -247,7 +247,6 @@ Reboot Traceroute Show Introduction - Welcome to Meshtastic Meshtastic is an open-source, off-grid, encrypted communication platform. The Meshtastic radios form a mesh network and communicate using the LoRa protocol to send text messages. …Let\'s get started! Connect your Meshtastic device by using either Bluetooth, Serial or WiFi. \n\nYou can see which devices are compatible at www.meshtastic.org/docs/hardware @@ -811,4 +810,46 @@ Firmware Edition Recent Network Devices Discovered Network Devices + + Get started + Welcome to + Stay Connected Anywhere + Communicate off-the-grid with your friends and community without cell service. + Create Your Own Networks + Easily set up private mesh networks for secure and reliable communication in remote areas. + Track and Share Locations + Share your location in real-time and keep your group coordinated with integrated GPS features. + App Notifications + Send Notifications + Incoming Messages + Notifications for channel and direct messages. + New Nodes + Notifications for newly discovered nodes. + Low Battery + Notifications for low battery alerts for the connected device. + Select packets sent as critical will ignore the mute switch and Do Not Disturb settings in the OS notification center. + Configure notification permissions + Phone Location + Meshtastic uses your phone\'s location to enable a number of features. You can update your location permissions at any time from settings. + Share Location + Use your phone GPS to send locations to your node to instead of using a hardware GPS on your node. + Enable Location Sharing + Distance Measurements + Display the distance between your phone and other Meshtastic nodes with positions. + Distance Filters + Filter the node list and mesh map based on proximity to your phone. + Mesh Map Location + Enables the blue location dot for your phone in the mesh map. + Configure Location Permissions + Skip + settings + Critical Alerts + To ensure you receive critical alerts, such as + SOS messages, even when your device is in "Do Not Disturb" mode, you need to grant special + permission. Please enable this in the notification settings. + + Configure Critical Alerts + Meshtastic uses notifications to keep you updated on new messages and other important events. You can update your notification permissions at any time from settings. + Next + Grant Permissions and Scan diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e280c4379..528df1499 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +accompanistPermissions = "0.37.3" adaptive = "1.2.0-alpha09" adaptive-navigation-suite = "1.3.2" agp = "8.11.1" @@ -50,6 +51,7 @@ zxing-core = "3.5.3" spotless = "7.2.1" [libraries] +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } agp = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } activity = { group = "androidx.activity", name = "activity" } actvity-ktx = { group = "androidx.activity", name = "activity-ktx" }