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>
This commit is contained in:
James Rich 2025-07-29 09:42:36 -05:00 committed by GitHub
parent c1408816a4
commit 2c6751a574
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2795 additions and 2513 deletions

View file

@ -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>(IMeshService.Stub::asInterface) {
override fun onConnected(service: IMeshService) {
serviceSetupJob?.cancel()
serviceSetupJob = lifecycleScope.handledLaunch {
serviceRepository.setMeshService(service)
private val mesh =
object : ServiceClient<IMeshService>(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)
}
}

View file

@ -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<String>): 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<String>): Boolean {
for (permission in permissions) {
if (shouldShowRequestPermissionRationale(permission)) {
return true
}
}
return false
}
private fun Context.getBluetoothPermissions(): Array<String> {
val requiredPermissions = mutableListOf<String>()
/**
* 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<String>): Array<String> = 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<String> {
val perms = mutableListOf<String>()
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<String> {
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<String> {
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<String> {
val perms = mutableListOf<String>()
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()

View file

@ -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
}
}

View file

@ -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()
}
val enabled = bluetoothRepository.state.map { it.enabled }
}

View file

@ -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<BluetoothAdapter?>,
private val bluetoothBroadcastReceiverLazy: dagger.Lazy<BluetoothBroadcastReceiver>,
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<BluetoothState> = _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<ScanResult> {
val filter = ScanFilter.Builder()
// Samsung doesn't seem to filter properly by service so this can't work
// see https://stackoverflow.com/questions/57981986/altbeacon-android-beacon-library-not-working-after-device-has-screen-off-for-a-s/57995960#57995960
// and https://stackoverflow.com/a/45590493
// .setServiceUuid(ParcelUuid(BluetoothInterface.BTM_SERVICE_UUID))
.build()
val 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<Int> = 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")

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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<DeviceListEntry>,
selectedDevice: String,
showBluetoothRationaleDialog: () -> Unit,
requestBluetoothPermission: (Array<String>) -> 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)
},
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<IntroSlide> {
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)
)
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
},
)
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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) }
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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")
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
},
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
},
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<FeatureUIData>,
additionalContent: (@Composable () -> Unit)? = null,
onSkip: () -> Unit,
onConfigure: () -> Unit,
@StringRes configureButtonTextRes: Int,
onAnnotationClick: (String) -> Unit,
) {
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(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()
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}
}
}
}

View file

@ -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

View file

@ -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<Boolean>,
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
},

View file

@ -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<String>()
// 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<String>()
@ -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")
}