mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
c1408816a4
commit
2c6751a574
28 changed files with 2795 additions and 2513 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
29
app/src/main/java/com/geeksville/mesh/ui/intro/IntroRoute.kt
Normal file
29
app/src/main/java/com/geeksville/mesh/ui/intro/IntroRoute.kt
Normal 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")
|
||||
}
|
||||
104
app/src/main/java/com/geeksville/mesh/ui/intro/IntroUiHelpers.kt
Normal file
104
app/src/main/java/com/geeksville/mesh/ui/intro/IntroUiHelpers.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
104
app/src/main/java/com/geeksville/mesh/ui/intro/WelcomeScreen.kt
Normal file
104
app/src/main/java/com/geeksville/mesh/ui/intro/WelcomeScreen.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue