mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
477 lines
23 KiB
Kotlin
477 lines
23 KiB
Kotlin
/*
|
|
* 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/>.
|
|
*/
|
|
|
|
@file:Suppress("MatchingDeclarationName")
|
|
|
|
package com.geeksville.mesh.ui
|
|
|
|
import android.Manifest
|
|
import android.os.Build
|
|
import androidx.compose.animation.AnimatedVisibility
|
|
import androidx.compose.animation.core.Animatable
|
|
import androidx.compose.animation.core.LinearEasing
|
|
import androidx.compose.animation.core.tween
|
|
import androidx.compose.animation.fadeIn
|
|
import androidx.compose.animation.fadeOut
|
|
import androidx.compose.animation.scaleIn
|
|
import androidx.compose.animation.scaleOut
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.imePadding
|
|
import androidx.compose.foundation.layout.recalculateWindowInsets
|
|
import androidx.compose.foundation.layout.safeDrawingPadding
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.rounded.Wifi
|
|
import androidx.compose.material3.Badge
|
|
import androidx.compose.material3.BadgedBox
|
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.LocalContentColor
|
|
import androidx.compose.material3.MaterialTheme.colorScheme
|
|
import androidx.compose.material3.PlainTooltip
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TooltipBox
|
|
import androidx.compose.material3.TooltipDefaults
|
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
|
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
|
import androidx.compose.material3.rememberTooltipState
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.livedata.observeAsState
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.drawWithCache
|
|
import androidx.compose.ui.graphics.BlendMode
|
|
import androidx.compose.ui.graphics.Brush
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.vector.ImageVector
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
import androidx.navigation.NavDestination
|
|
import androidx.navigation.NavDestination.Companion.hasRoute
|
|
import androidx.navigation.NavDestination.Companion.hierarchy
|
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
|
import androidx.navigation.compose.NavHost
|
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
|
import androidx.navigation.compose.rememberNavController
|
|
import com.geeksville.mesh.BuildConfig
|
|
import com.geeksville.mesh.model.BTScanModel
|
|
import com.geeksville.mesh.model.UIViewModel
|
|
import com.geeksville.mesh.navigation.channelsGraph
|
|
import com.geeksville.mesh.navigation.connectionsGraph
|
|
import com.geeksville.mesh.navigation.contactsGraph
|
|
import com.geeksville.mesh.navigation.mapGraph
|
|
import com.geeksville.mesh.navigation.nodesGraph
|
|
import com.geeksville.mesh.navigation.settingsGraph
|
|
import com.geeksville.mesh.repository.radio.MeshActivity
|
|
import com.geeksville.mesh.service.MeshService
|
|
import com.geeksville.mesh.ui.connections.DeviceType
|
|
import com.geeksville.mesh.ui.connections.components.ConnectionsNavIcon
|
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
|
import com.google.accompanist.permissions.isGranted
|
|
import com.google.accompanist.permissions.rememberPermissionState
|
|
import kotlinx.coroutines.flow.collectLatest
|
|
import kotlinx.coroutines.launch
|
|
import org.jetbrains.compose.resources.StringResource
|
|
import org.jetbrains.compose.resources.getString
|
|
import org.jetbrains.compose.resources.stringResource
|
|
import org.meshtastic.core.model.DeviceVersion
|
|
import org.meshtastic.core.navigation.ConnectionsRoutes
|
|
import org.meshtastic.core.navigation.ContactsRoutes
|
|
import org.meshtastic.core.navigation.MapRoutes
|
|
import org.meshtastic.core.navigation.NodesRoutes
|
|
import org.meshtastic.core.navigation.Route
|
|
import org.meshtastic.core.navigation.SettingsRoutes
|
|
import org.meshtastic.core.service.ConnectionState
|
|
import org.meshtastic.core.strings.Res
|
|
import org.meshtastic.core.strings.app_too_old
|
|
import org.meshtastic.core.strings.bottom_nav_settings
|
|
import org.meshtastic.core.strings.client_notification
|
|
import org.meshtastic.core.strings.compromised_keys
|
|
import org.meshtastic.core.strings.connected
|
|
import org.meshtastic.core.strings.connections
|
|
import org.meshtastic.core.strings.conversations
|
|
import org.meshtastic.core.strings.device_sleeping
|
|
import org.meshtastic.core.strings.disconnected
|
|
import org.meshtastic.core.strings.firmware_old
|
|
import org.meshtastic.core.strings.firmware_too_old
|
|
import org.meshtastic.core.strings.map
|
|
import org.meshtastic.core.strings.must_update
|
|
import org.meshtastic.core.strings.nodes
|
|
import org.meshtastic.core.strings.okay
|
|
import org.meshtastic.core.strings.should_update
|
|
import org.meshtastic.core.strings.should_update_firmware
|
|
import org.meshtastic.core.strings.traceroute
|
|
import org.meshtastic.core.ui.component.MultipleChoiceAlertDialog
|
|
import org.meshtastic.core.ui.component.SimpleAlertDialog
|
|
import org.meshtastic.core.ui.icon.Conversations
|
|
import org.meshtastic.core.ui.icon.Map
|
|
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
|
import org.meshtastic.core.ui.icon.Nodes
|
|
import org.meshtastic.core.ui.icon.Settings
|
|
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
|
import org.meshtastic.core.ui.share.SharedContactDialog
|
|
import org.meshtastic.core.ui.theme.StatusColors.StatusBlue
|
|
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
|
import org.meshtastic.feature.node.metrics.annotateTraceroute
|
|
import org.meshtastic.proto.MeshProtos
|
|
import timber.log.Timber
|
|
|
|
enum class TopLevelDestination(val label: StringResource, val icon: ImageVector, val route: Route) {
|
|
Conversations(Res.string.conversations, MeshtasticIcons.Conversations, ContactsRoutes.ContactsGraph),
|
|
Nodes(Res.string.nodes, MeshtasticIcons.Nodes, NodesRoutes.NodesGraph),
|
|
Map(Res.string.map, MeshtasticIcons.Map, MapRoutes.Map),
|
|
Settings(Res.string.bottom_nav_settings, MeshtasticIcons.Settings, SettingsRoutes.SettingsGraph()),
|
|
Connections(Res.string.connections, Icons.Rounded.Wifi, ConnectionsRoutes.ConnectionsGraph),
|
|
;
|
|
|
|
companion object {
|
|
fun fromNavDestination(destination: NavDestination?): TopLevelDestination? =
|
|
entries.find { dest -> destination?.hierarchy?.any { it.hasRoute(dest.route::class) } == true }
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
|
|
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
|
@Composable
|
|
fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanModel = hiltViewModel()) {
|
|
val navController = rememberNavController()
|
|
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
|
|
val requestChannelSet by uIViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
|
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
|
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
val notificationPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
|
|
LaunchedEffect(connectionState, notificationPermissionState) {
|
|
if (connectionState == ConnectionState.CONNECTED && !notificationPermissionState.status.isGranted) {
|
|
notificationPermissionState.launchPermissionRequest()
|
|
}
|
|
}
|
|
}
|
|
|
|
if (connectionState == ConnectionState.CONNECTED) {
|
|
sharedContactRequested?.let {
|
|
SharedContactDialog(sharedContact = it, onDismiss = { uIViewModel.clearSharedContactRequested() })
|
|
}
|
|
|
|
requestChannelSet?.let { newChannelSet ->
|
|
ScannedQrCodeDialog(newChannelSet, onDismiss = { uIViewModel.clearRequestChannelUrl() })
|
|
}
|
|
}
|
|
|
|
uIViewModel.AddNavigationTrackingEffect(navController)
|
|
|
|
VersionChecks(uIViewModel)
|
|
val alertDialogState by uIViewModel.currentAlert.collectAsStateWithLifecycle()
|
|
alertDialogState?.let { state ->
|
|
if (state.choices.isNotEmpty()) {
|
|
MultipleChoiceAlertDialog(
|
|
title = state.title,
|
|
message = state.message,
|
|
choices = state.choices,
|
|
onDismissRequest = { state.onDismiss?.let { it() } },
|
|
)
|
|
} else {
|
|
SimpleAlertDialog(
|
|
title = state.title,
|
|
message = state.message,
|
|
html = state.html,
|
|
onConfirmRequest = { state.onConfirm?.let { it() } },
|
|
onDismissRequest = { state.onDismiss?.let { it() } },
|
|
)
|
|
}
|
|
}
|
|
|
|
val clientNotification by uIViewModel.clientNotification.collectAsStateWithLifecycle()
|
|
clientNotification?.let { notification ->
|
|
var message = notification.message
|
|
val compromisedKeys =
|
|
if (notification.hasLowEntropyKey() || notification.hasDuplicatedPublicKey()) {
|
|
message = stringResource(Res.string.compromised_keys)
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
SimpleAlertDialog(
|
|
title = Res.string.client_notification,
|
|
text = { Text(text = message) },
|
|
onConfirm = {
|
|
if (compromisedKeys) {
|
|
navController.navigate(SettingsRoutes.Security)
|
|
}
|
|
uIViewModel.clearClientNotification(notification)
|
|
},
|
|
onDismiss = { uIViewModel.clearClientNotification(notification) },
|
|
)
|
|
}
|
|
|
|
val traceRouteResponse by uIViewModel.tracerouteResponse.observeAsState()
|
|
traceRouteResponse?.let { response ->
|
|
SimpleAlertDialog(
|
|
title = Res.string.traceroute,
|
|
text = {
|
|
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
|
Text(text = annotateTraceroute(response))
|
|
}
|
|
},
|
|
dismissText = stringResource(Res.string.okay),
|
|
onDismiss = { uIViewModel.clearTracerouteResponse() },
|
|
)
|
|
}
|
|
val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())
|
|
val currentDestination = navController.currentBackStackEntryAsState().value?.destination
|
|
val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination)
|
|
|
|
// State for determining the connection type icon to display
|
|
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
|
|
|
|
// State for managing the glow animation around the Connections icon
|
|
var currentGlowColor by remember { mutableStateOf(Color.Transparent) }
|
|
val animatedGlowAlpha = remember { Animatable(0f) }
|
|
val coroutineScope = rememberCoroutineScope()
|
|
val capturedColorScheme = colorScheme // Capture current colorScheme instance for LaunchedEffect
|
|
|
|
val sendColor = capturedColorScheme.StatusGreen
|
|
val receiveColor = capturedColorScheme.StatusBlue
|
|
LaunchedEffect(uIViewModel.meshActivity, capturedColorScheme) {
|
|
uIViewModel.meshActivity.collectLatest { activity ->
|
|
val newTargetColor =
|
|
when (activity) {
|
|
is MeshActivity.Send -> sendColor
|
|
is MeshActivity.Receive -> receiveColor
|
|
}
|
|
|
|
currentGlowColor = newTargetColor
|
|
// Stop any existing animation and launch a new one.
|
|
// Launching in a new coroutine ensures the collect block is not suspended.
|
|
coroutineScope.launch {
|
|
animatedGlowAlpha.stop() // Stop before snapping/animating
|
|
animatedGlowAlpha.snapTo(1.0f) // Show glow instantly
|
|
animatedGlowAlpha.animateTo(
|
|
targetValue = 0.0f, // Fade out
|
|
animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
NavigationSuiteScaffold(
|
|
modifier = Modifier.fillMaxSize(),
|
|
navigationSuiteItems = {
|
|
TopLevelDestination.entries.forEach { destination ->
|
|
val isSelected = destination == topLevelDestination
|
|
val isConnectionsRoute = destination == TopLevelDestination.Connections
|
|
item(
|
|
icon = {
|
|
TooltipBox(
|
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
|
|
tooltip = {
|
|
PlainTooltip {
|
|
Text(
|
|
if (isConnectionsRoute) {
|
|
when (connectionState) {
|
|
ConnectionState.CONNECTED -> stringResource(Res.string.connected)
|
|
ConnectionState.DEVICE_SLEEP ->
|
|
stringResource(Res.string.device_sleeping)
|
|
ConnectionState.DISCONNECTED -> stringResource(Res.string.disconnected)
|
|
}
|
|
} else {
|
|
stringResource(destination.label)
|
|
},
|
|
)
|
|
}
|
|
},
|
|
state = rememberTooltipState(),
|
|
) {
|
|
if (isConnectionsRoute) {
|
|
Box(
|
|
modifier =
|
|
Modifier.drawWithCache {
|
|
onDrawWithContent {
|
|
drawContent()
|
|
if (animatedGlowAlpha.value > 0f) {
|
|
val glowRadius = size.minDimension
|
|
drawCircle(
|
|
brush =
|
|
Brush.radialGradient(
|
|
colors =
|
|
listOf(
|
|
currentGlowColor.copy(
|
|
alpha = 0.8f * animatedGlowAlpha.value,
|
|
),
|
|
currentGlowColor.copy(
|
|
alpha = 0.4f * animatedGlowAlpha.value,
|
|
),
|
|
Color.Transparent,
|
|
),
|
|
center = center,
|
|
radius = glowRadius,
|
|
),
|
|
radius = glowRadius,
|
|
blendMode = BlendMode.Screen,
|
|
)
|
|
}
|
|
}
|
|
},
|
|
) {
|
|
ConnectionsNavIcon(
|
|
connectionState = connectionState,
|
|
deviceType = DeviceType.fromAddress(selectedDevice),
|
|
)
|
|
}
|
|
} else {
|
|
BadgedBox(
|
|
badge = {
|
|
if (destination == TopLevelDestination.Conversations) {
|
|
// Keep track of the last non-zero count for display during exit animation
|
|
var lastNonZeroCount by remember { mutableIntStateOf(unreadMessageCount) }
|
|
if (unreadMessageCount > 0) {
|
|
lastNonZeroCount = unreadMessageCount
|
|
}
|
|
AnimatedVisibility(
|
|
visible = unreadMessageCount > 0,
|
|
enter = scaleIn() + fadeIn(),
|
|
exit = scaleOut() + fadeOut(),
|
|
) {
|
|
Badge { Text(lastNonZeroCount.toString()) }
|
|
}
|
|
}
|
|
},
|
|
) {
|
|
Icon(
|
|
imageVector = destination.icon,
|
|
contentDescription = stringResource(destination.label),
|
|
tint = LocalContentColor.current,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
selected = isSelected,
|
|
label = {
|
|
if (navSuiteType != NavigationSuiteType.ShortNavigationBarCompact) {
|
|
Text(stringResource(destination.label))
|
|
}
|
|
},
|
|
onClick = {
|
|
navController.navigate(destination.route) {
|
|
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
|
launchSingleTop = true
|
|
}
|
|
},
|
|
)
|
|
}
|
|
},
|
|
) {
|
|
NavHost(
|
|
navController = navController,
|
|
startDestination = NodesRoutes.NodesGraph,
|
|
modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding().imePadding(),
|
|
) {
|
|
contactsGraph(navController)
|
|
nodesGraph(navController)
|
|
mapGraph(navController)
|
|
channelsGraph(navController)
|
|
connectionsGraph(navController)
|
|
settingsGraph(navController)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
|
private fun VersionChecks(viewModel: UIViewModel) {
|
|
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
|
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
|
|
val context = LocalContext.current
|
|
|
|
val myFirmwareVersion = myNodeInfo?.firmwareVersion
|
|
|
|
val firmwareEdition by viewModel.firmwareEdition.collectAsStateWithLifecycle(null)
|
|
|
|
val latestStableFirmwareRelease by
|
|
viewModel.latestStableFirmwareRelease.collectAsStateWithLifecycle(DeviceVersion("2.6.4"))
|
|
LaunchedEffect(connectionState, firmwareEdition) {
|
|
if (connectionState == ConnectionState.CONNECTED) {
|
|
firmwareEdition?.let { edition ->
|
|
Timber.d("FirmwareEdition: ${edition.name}")
|
|
when (edition) {
|
|
MeshProtos.FirmwareEdition.VANILLA -> {
|
|
// Handle any specific logic for VANILLA firmware edition if needed
|
|
}
|
|
|
|
else -> {
|
|
// Handle other firmware editions if needed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if the device is running an old app version or firmware version
|
|
LaunchedEffect(connectionState, myNodeInfo) {
|
|
if (connectionState == ConnectionState.CONNECTED) {
|
|
myNodeInfo?.let { info ->
|
|
val isOld = info.minAppVersion > BuildConfig.VERSION_CODE && BuildConfig.DEBUG.not()
|
|
if (isOld) {
|
|
viewModel.showAlert(
|
|
getString(Res.string.app_too_old),
|
|
getString(Res.string.must_update),
|
|
dismissable = false,
|
|
onConfirm = {
|
|
val service = viewModel.meshService ?: return@showAlert
|
|
MeshService.changeDeviceAddress(context, service, "n")
|
|
},
|
|
)
|
|
} else {
|
|
myFirmwareVersion?.let {
|
|
val curVer = DeviceVersion(it)
|
|
if (curVer < MeshService.absoluteMinDeviceVersion) {
|
|
val title = getString(Res.string.firmware_too_old)
|
|
val message = getString(Res.string.firmware_old)
|
|
viewModel.showAlert(
|
|
title = title,
|
|
html = message,
|
|
dismissable = false,
|
|
onConfirm = {
|
|
val service = viewModel.meshService ?: return@showAlert
|
|
MeshService.changeDeviceAddress(context, service, "n")
|
|
},
|
|
)
|
|
} else if (curVer < MeshService.minDeviceVersion) {
|
|
val title = getString(Res.string.should_update_firmware)
|
|
val message = getString(Res.string.should_update, latestStableFirmwareRelease.asString)
|
|
viewModel.showAlert(title = title, message = message, dismissable = false, onConfirm = {})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|