Brownfield gap remediation: 28 tasks + intro commonMain migration (#5401)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-11 15:10:23 -05:00
committed by GitHub
parent 6eacee626b
commit 95c3bc0bce
72 changed files with 4408 additions and 246 deletions
@@ -18,8 +18,15 @@ package org.meshtastic.feature.connections.ui.components
/** Visual style for [ConnectionActionButton]. Maps to the four canonical M3 button variants. */
enum class ConnectionActionButtonStyle {
/** Solid-fill button for the primary action in a group (e.g. "Start scan"). */
Filled,
/** Tonal (filled-tonal) button for secondary prominence (e.g. "Add device manually"). */
Tonal,
/** Outlined button for neutral or tertiary actions (e.g. "Disconnect"). */
Outlined,
/** Text-only button for the least prominent action (e.g. inline toggles). */
Text,
}
@@ -0,0 +1,310 @@
/*
* Copyright (c) 2026 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("MagicNumber")
package org.meshtastic.feature.firmware.ota
import io.ktor.network.selector.SelectorManager
import io.ktor.network.sockets.ServerSocket
import io.ktor.network.sockets.Socket
import io.ktor.network.sockets.aSocket
import io.ktor.network.sockets.openReadChannel
import io.ktor.network.sockets.openWriteChannel
import io.ktor.network.sockets.port
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.ByteWriteChannel
import io.ktor.utils.io.readAvailable
import io.ktor.utils.io.readLine
import io.ktor.utils.io.writeStringUtf8
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class WifiOtaTransportTest {
@Test
fun `connect succeeds when TCP socket is established`() = runTest {
val server = TestTcpOtaServer.start()
val transport = WifiOtaTransport(deviceIpAddress = LOCALHOST, port = server.port)
try {
val result = transport.connect()
assertTrue(result.isSuccess, "connect() must succeed: ${result.exceptionOrNull()}")
assertNotNull(server.awaitConnection())
} finally {
transport.close()
server.close()
}
}
@Test
fun `connect fails when device is unreachable`() = runTest {
val server = TestTcpOtaServer.start()
val port = server.port
server.close()
val transport = WifiOtaTransport(deviceIpAddress = LOCALHOST, port = port)
try {
val result = transport.connect()
assertTrue(result.isFailure)
assertNotNull(result.exceptionOrNull())
} finally {
transport.close()
}
}
@Test
fun `startOta sends OTA command and succeeds on OK response`() = runTest {
val (transport, server, connection) = createConnectedTransport()
try {
val startJob = async(Dispatchers.Default) { transport.startOta(1024L, "abc123hash") }
assertEquals("OTA 1024 abc123hash", connection.readLine())
connection.sendResponse("OK")
assertTrue(startJob.await().isSuccess)
} finally {
transport.close()
server.close()
}
}
@Test
fun `startOta reports erasing status before succeeding`() = runTest {
val (transport, server, connection) = createConnectedTransport()
val statuses = mutableListOf<OtaHandshakeStatus>()
try {
val startJob =
async(Dispatchers.Default) { transport.startOta(2048L, "hash256") { status -> statuses += status } }
assertEquals("OTA 2048 hash256", connection.readLine())
connection.sendResponse("ERASING")
connection.sendResponse("OK")
assertTrue(startJob.await().isSuccess)
assertEquals(1, statuses.size)
assertIs<OtaHandshakeStatus.Erasing>(statuses.single())
} finally {
transport.close()
server.close()
}
}
@Test
fun `startOta fails on hash rejected response`() = runTest {
val (transport, server, connection) = createConnectedTransport()
try {
val startJob = async(Dispatchers.Default) { transport.startOta(1024L, "bad-hash") }
assertEquals("OTA 1024 bad-hash", connection.readLine())
connection.sendResponse("ERR Hash Rejected")
val result = startJob.await()
assertTrue(result.isFailure)
assertIs<OtaProtocolException.HashRejected>(result.exceptionOrNull())
} finally {
transport.close()
server.close()
}
}
@Test
fun `streamFirmware sends 1024-byte chunks and waits for final OK`() = runTest {
val (transport, server, connection) = createConnectedTransport()
val firmware = ByteArray(2500) { (it % 251).toByte() }
val progressValues = mutableListOf<Float>()
try {
val startJob = async(Dispatchers.Default) { transport.startOta(firmware.size.toLong(), "firmware-hash") }
assertEquals("OTA 2500 firmware-hash", connection.readLine())
connection.sendResponse("OK")
assertTrue(startJob.await().isSuccess)
val streamJob =
async(Dispatchers.Default) {
transport.streamFirmware(firmware, WifiOtaTransport.RECOMMENDED_CHUNK_SIZE) { progress ->
progressValues += progress
}
}
assertContentEquals(firmware, connection.readExactly(firmware.size))
connection.sendResponse("ACK")
connection.sendResponse("OK")
assertTrue(streamJob.await().isSuccess)
assertEquals(3, progressValues.size)
assertEquals(1024f / 2500f, progressValues[0], 0.0001f)
assertEquals(2048f / 2500f, progressValues[1], 0.0001f)
assertEquals(1.0f, progressValues[2], 0.0001f)
} finally {
transport.close()
server.close()
}
}
@Test
fun `streamFirmware fails on hash mismatch verification error`() = runTest {
val (transport, server, connection) = createConnectedTransport()
val firmware = byteArrayOf(0x01, 0x02, 0x03, 0x04)
try {
val startJob = async(Dispatchers.Default) { transport.startOta(firmware.size.toLong(), "firmware-hash") }
assertEquals("OTA 4 firmware-hash", connection.readLine())
connection.sendResponse("OK")
assertTrue(startJob.await().isSuccess)
val streamJob =
async(Dispatchers.Default) {
transport.streamFirmware(firmware, WifiOtaTransport.RECOMMENDED_CHUNK_SIZE) {}
}
assertContentEquals(firmware, connection.readExactly(firmware.size))
connection.sendResponse("ERR Hash Mismatch")
val result = streamJob.await()
assertTrue(result.isFailure)
assertIs<OtaProtocolException.VerificationFailed>(result.exceptionOrNull())
} finally {
transport.close()
server.close()
}
}
@Test
fun `close resets transport and closes TCP connection`() = runTest {
val (transport, server, connection) = createConnectedTransport()
try {
transport.close()
assertNull(withTimeout(1_000L) { connection.readLine() })
val result = transport.startOta(1L, "hash")
assertTrue(result.isFailure)
assertIs<OtaProtocolException.ConnectionFailed>(result.exceptionOrNull())
} finally {
server.close()
}
}
private suspend fun createConnectedTransport(): Triple<WifiOtaTransport, TestTcpOtaServer, TestTcpOtaConnection> {
val server = TestTcpOtaServer.start()
val transport = WifiOtaTransport(deviceIpAddress = LOCALHOST, port = server.port)
val result = transport.connect()
assertTrue(result.isSuccess, "connect() must succeed: ${result.exceptionOrNull()}")
return Triple(transport, server, server.awaitConnection())
}
private class TestTcpOtaServer
private constructor(
private val selectorManager: SelectorManager,
private val serverSocket: ServerSocket,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val acceptedConnection = CompletableDeferred<TestTcpOtaConnection>()
val port: Int = serverSocket.localAddress.port()
init {
scope.launch {
runCatching {
val socket = serverSocket.accept()
acceptedConnection.complete(
TestTcpOtaConnection(
socket = socket,
readChannel = socket.openReadChannel(),
writeChannel = socket.openWriteChannel(autoFlush = true),
),
)
}
.onFailure { acceptedConnection.completeExceptionally(it) }
}
}
suspend fun awaitConnection(): TestTcpOtaConnection = acceptedConnection.await()
suspend fun close() {
if (acceptedConnection.isCompleted && !acceptedConnection.isCancelled) {
runCatching { acceptedConnection.await().close() }
}
runCatching { serverSocket.close() }
runCatching { selectorManager.close() }
scope.cancel()
}
companion object {
suspend fun start(): TestTcpOtaServer {
val selectorManager = SelectorManager(Dispatchers.Default)
val serverSocket = aSocket(selectorManager).tcp().bind(hostname = LOCALHOST, port = 0)
return TestTcpOtaServer(selectorManager, serverSocket)
}
}
}
private class TestTcpOtaConnection(
private val socket: Socket,
private val readChannel: ByteReadChannel,
private val writeChannel: ByteWriteChannel,
) {
suspend fun readLine(): String? = readChannel.readLine()
suspend fun sendResponse(text: String) {
writeChannel.writeStringUtf8("$text\n")
writeChannel.flush()
}
suspend fun readExactly(byteCount: Int): ByteArray {
val bytes = ByteArray(byteCount)
var offset = 0
while (offset < byteCount) {
readChannel.awaitContent()
val bytesRead = readChannel.readAvailable(bytes, offset, byteCount - offset)
if (bytesRead == -1) break
offset += bytesRead
}
return bytes.copyOf(offset)
}
suspend fun close() {
runCatching { socket.close() }
}
}
private companion object {
const val LOCALHOST = "127.0.0.1"
}
}
+1
View File
@@ -37,5 +37,6 @@ kotlin {
implementation(libs.jetbrains.navigation3.ui)
}
androidMain.dependencies { implementation(projects.core.service) }
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2026 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 org.meshtastic.feature.intro
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
@OptIn(ExperimentalPermissionsApi::class)
internal class AndroidIntroPermissions(
private val bluetoothState: MultiplePermissionsState,
private val locationState: MultiplePermissionsState,
private val notificationState: PermissionState?,
) : IntroPermissions {
override val bluetooth: IntroPermissionState =
object : IntroPermissionState {
override val isGranted: Boolean
get() = bluetoothState.allPermissionsGranted
override fun launchRequest() = bluetoothState.launchMultiplePermissionRequest()
}
override val location: IntroPermissionState =
object : IntroPermissionState {
override val isGranted: Boolean
get() = locationState.allPermissionsGranted
override fun launchRequest() = locationState.launchMultiplePermissionRequest()
}
override val notification: IntroPermissionState? =
notificationState?.let { state ->
object : IntroPermissionState {
override val isGranted: Boolean
get() = state.status.isGranted
override fun launchRequest() = state.launchPermissionRequest()
}
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 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 org.meshtastic.feature.intro
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import org.meshtastic.core.service.NotificationChannels
internal class AndroidIntroSettingsNavigator(private val context: Context) : IntroSettingsNavigator {
override fun openAppSettings() {
val intent =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
context.startActivity(intent)
}
override fun openCriticalAlertsSettings() {
val intent =
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, NotificationChannels.ALERTS)
}
context.startActivity(intent)
}
}
@@ -19,6 +19,10 @@ package org.meshtastic.feature.intro
import android.Manifest
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
@@ -35,6 +39,8 @@ import org.meshtastic.core.ui.component.MeshtasticNavDisplay
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun AppIntroductionScreen(onDone: () -> Unit, viewModel: IntroViewModel) {
val context = LocalContext.current
val notificationPermissionState: PermissionState? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
@@ -50,23 +56,28 @@ fun AppIntroductionScreen(onDone: () -> Unit, viewModel: IntroViewModel) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
// On older versions, location permission is used for scanning.
emptyList()
}
val bluetoothPermissionState = rememberMultiplePermissionsState(permissions = bluetoothPermissions)
val permissions =
remember(notificationPermissionState, locationPermissionState, bluetoothPermissionState) {
AndroidIntroPermissions(
bluetoothState = bluetoothPermissionState,
locationState = locationPermissionState,
notificationState = notificationPermissionState,
)
}
val settingsNavigator = remember(context) { AndroidIntroSettingsNavigator(context) }
val backStack = rememberNavBackStack(Welcome)
MeshtasticNavDisplay(
backStack = backStack,
entryProvider =
introNavGraph(
CompositionLocalProvider(
LocalIntroPermissions provides permissions,
LocalIntroSettingsNavigator provides settingsNavigator,
) {
MeshtasticNavDisplay(
backStack = backStack,
viewModel = viewModel,
notificationPermissionState = notificationPermissionState,
bluetoothPermissionState = bluetoothPermissionState,
locationPermissionState = locationPermissionState,
onDone = onDone,
),
)
entryProvider = entryProvider { introGraph(backStack = backStack, viewModel = viewModel, onDone = onDone) },
)
}
}
@@ -16,11 +16,9 @@
*/
package org.meshtastic.feature.intro
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bluetooth_feature_config
import org.meshtastic.core.resources.bluetooth_feature_config_description
@@ -34,6 +32,7 @@ import org.meshtastic.core.resources.settings
import org.meshtastic.core.ui.icon.Antenna
import org.meshtastic.core.ui.icon.Bluetooth
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
/**
* Screen for configuring Bluetooth permissions during the app introduction. It explains why Bluetooth permissions are
@@ -43,12 +42,17 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
* button.
* @param onSkip Callback invoked if the user chooses to skip Bluetooth permission setup.
* @param onConfigure Callback invoked when the user proceeds to configure or grant permissions.
* @param onOpenSettings Callback invoked when the user taps the settings link.
*/
@Composable
internal fun BluetoothScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfigure: () -> Unit) {
val context = LocalContext.current
internal fun BluetoothScreen(
showNextButton: Boolean,
onSkip: () -> Unit,
onConfigure: () -> Unit,
onOpenSettings: () -> Unit,
) {
val annotatedString =
context.createClickableAnnotatedString(
createClickableAnnotatedString(
fullTextRes = Res.string.permission_missing_31,
linkTextRes = Res.string.settings,
tag = SETTINGS_TAG,
@@ -75,10 +79,12 @@ internal fun BluetoothScreen(showNextButton: Boolean, onSkip: () -> Unit, onConf
onSkip = onSkip,
onConfigure = onConfigure,
configureButtonTextRes = if (showNextButton) Res.string.next else Res.string.configure_bluetooth_permissions,
onAnnotationClick = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", context.packageName, null)
context.startActivity(intent)
},
onAnnotationClick = { onOpenSettings() },
)
}
@PreviewLightDark
@Composable
private fun BluetoothScreenPreview() {
AppTheme { Surface { BluetoothScreen(showNextButton = false, onSkip = {}, onConfigure = {}, onOpenSettings = {}) } }
}
@@ -25,12 +25,14 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@@ -38,6 +40,7 @@ import org.meshtastic.core.resources.configure_critical_alerts
import org.meshtastic.core.resources.critical_alerts
import org.meshtastic.core.resources.critical_alerts_dnd_request_text
import org.meshtastic.core.resources.skip
import org.meshtastic.core.ui.theme.AppTheme
/**
* Screen for explaining and guiding the user to configure critical alert settings. This screen is part of the app
@@ -77,3 +80,9 @@ internal fun CriticalAlertsScreen(onSkip: () -> Unit, onConfigure: () -> Unit) {
}
}
}
@PreviewLightDark
@Composable
private fun CriticalAlertsScreenPreview() {
AppTheme { Surface { CriticalAlertsScreen(onSkip = {}, onConfigure = {}) } }
}
@@ -16,35 +16,17 @@
*/
package org.meshtastic.feature.intro
import android.content.Intent
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
/**
* Provides the navigation graph for the application introduction flow. The flow follows the hierarchy of necessity:
* Core Connection -> Shared Location -> Notifications.
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
/** Navigation graph for the application introduction / onboarding flow. */
@Suppress("LongMethod")
internal fun introNavGraph(
internal fun EntryProviderScope<NavKey>.introGraph(
backStack: NavBackStack<NavKey>,
viewModel: IntroViewModel,
notificationPermissionState: PermissionState?,
bluetoothPermissionState: MultiplePermissionsState,
locationPermissionState: MultiplePermissionsState,
onDone: () -> Unit,
) = entryProvider {
val context = LocalContext.current
) {
fun navigateToNext(current: NavKey, permissionsGranted: Boolean = true) {
val next = viewModel.getNextKey(current, permissionsGranted)
if (next != null) {
@@ -57,7 +39,9 @@ internal fun introNavGraph(
entry<Welcome> { WelcomeScreen(onGetStarted = { navigateToNext(Welcome) }) }
entry<Bluetooth> {
val isGranted = bluetoothPermissionState.allPermissionsGranted
val permissions = LocalIntroPermissions.current
val settingsNavigator = LocalIntroSettingsNavigator.current
val isGranted = permissions.bluetooth.isGranted
BluetoothScreen(
showNextButton = isGranted,
onSkip = { navigateToNext(Bluetooth) },
@@ -65,14 +49,17 @@ internal fun introNavGraph(
if (isGranted) {
navigateToNext(Bluetooth)
} else {
bluetoothPermissionState.launchMultiplePermissionRequest()
permissions.bluetooth.launchRequest()
}
},
onOpenSettings = { settingsNavigator.openAppSettings() },
)
}
entry<Location> {
val isGranted = locationPermissionState.allPermissionsGranted
val permissions = LocalIntroPermissions.current
val settingsNavigator = LocalIntroSettingsNavigator.current
val isGranted = permissions.location.isGranted
LocationScreen(
showNextButton = isGranted,
onSkip = { navigateToNext(Location) },
@@ -80,37 +67,38 @@ internal fun introNavGraph(
if (isGranted) {
navigateToNext(Location)
} else {
locationPermissionState.launchMultiplePermissionRequest()
permissions.location.launchRequest()
}
},
onOpenSettings = { settingsNavigator.openAppSettings() },
)
}
entry<Notifications> {
val isGranted = notificationPermissionState?.status?.isGranted ?: true
val permissions = LocalIntroPermissions.current
val settingsNavigator = LocalIntroSettingsNavigator.current
val notificationPermission = permissions.notification
val isGranted = notificationPermission?.isGranted ?: true
NotificationsScreen(
showNextButton = isGranted,
onSkip = onDone,
onConfigure = {
if (notificationPermissionState != null && !isGranted) {
notificationPermissionState.launchPermissionRequest()
if (notificationPermission != null && !isGranted) {
notificationPermission.launchRequest()
} else {
navigateToNext(Notifications, permissionsGranted = isGranted)
}
},
onOpenSettings = { settingsNavigator.openAppSettings() },
)
}
entry<CriticalAlerts> {
val settingsNavigator = LocalIntroSettingsNavigator.current
CriticalAlertsScreen(
onSkip = onDone,
onConfigure = {
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)
settingsNavigator.openCriticalAlertsSettings()
onDone()
},
)
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2026 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 org.meshtastic.feature.intro
import androidx.compose.runtime.staticCompositionLocalOf
/** Platform-agnostic permission state for the intro flow. */
interface IntroPermissionState {
val isGranted: Boolean
fun launchRequest()
}
/** Aggregated permission states needed by the intro onboarding flow. */
interface IntroPermissions {
val bluetooth: IntroPermissionState
val location: IntroPermissionState
val notification: IntroPermissionState?
}
/** Provides platform-specific permission states to the intro nav graph. */
@Suppress("CompositionLocalAllowlist")
val LocalIntroPermissions = staticCompositionLocalOf<IntroPermissions> { error("IntroPermissions not provided") }
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 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 org.meshtastic.feature.intro
import androidx.compose.runtime.staticCompositionLocalOf
/** Platform-agnostic navigator for opening system settings from the intro flow. */
interface IntroSettingsNavigator {
fun openAppSettings()
fun openCriticalAlertsSettings()
}
/** Provides platform-specific settings navigation to the intro screens. */
@Suppress("CompositionLocalAllowlist")
val LocalIntroSettingsNavigator =
staticCompositionLocalOf<IntroSettingsNavigator> { error("IntroSettingsNavigator not provided") }
@@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.intro
import android.content.Context
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -78,7 +77,7 @@ internal fun FeatureRow(feature: FeatureUIData) {
* @return An [AnnotatedString] with the specified portion styled and annotated.
*/
@Composable
internal fun Context.createClickableAnnotatedString(
internal fun createClickableAnnotatedString(
fullTextRes: StringResource,
linkTextRes: StringResource,
tag: String,
@@ -16,11 +16,9 @@
*/
package org.meshtastic.feature.intro
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.configure_location_permissions
import org.meshtastic.core.resources.distance_filters
@@ -38,6 +36,7 @@ import org.meshtastic.core.resources.share_location_description
import org.meshtastic.core.ui.icon.HardwareModel
import org.meshtastic.core.ui.icon.LocationOn
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
/**
* Screen for configuring location permissions during the app introduction. It explains why location permissions are
@@ -47,12 +46,17 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
* 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.
* @param onOpenSettings Callback invoked when the user taps the settings link.
*/
@Composable
internal fun LocationScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfigure: () -> Unit) {
val context = LocalContext.current
internal fun LocationScreen(
showNextButton: Boolean,
onSkip: () -> Unit,
onConfigure: () -> Unit,
onOpenSettings: () -> Unit,
) {
val annotatedString =
context.createClickableAnnotatedString(
createClickableAnnotatedString(
fullTextRes = Res.string.phone_location_description,
linkTextRes = Res.string.settings,
tag = SETTINGS_TAG,
@@ -71,12 +75,12 @@ internal fun LocationScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfi
subtitleRes = Res.string.distance_measurements_description,
),
FeatureUIData(
icon = MeshtasticIcons.HardwareModel, // Consider a different icon if appropriate
icon = MeshtasticIcons.HardwareModel,
titleRes = Res.string.distance_filters,
subtitleRes = Res.string.distance_filters_description,
),
FeatureUIData(
icon = MeshtasticIcons.LocationOn, // Consider a different icon if appropriate
icon = MeshtasticIcons.LocationOn,
titleRes = Res.string.mesh_map_location,
subtitleRes = Res.string.mesh_map_location_description,
),
@@ -89,10 +93,12 @@ internal fun LocationScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfi
onSkip = onSkip,
onConfigure = onConfigure,
configureButtonTextRes = if (showNextButton) Res.string.next else Res.string.configure_location_permissions,
onAnnotationClick = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", context.packageName, null)
context.startActivity(intent)
},
onAnnotationClick = { onOpenSettings() },
)
}
@PreviewLightDark
@Composable
private fun LocationScreenPreview() {
AppTheme { Surface { LocationScreen(showNextButton = false, onSkip = {}, onConfigure = {}, onOpenSettings = {}) } }
}
@@ -16,11 +16,9 @@
*/
package org.meshtastic.feature.intro
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_notifications
import org.meshtastic.core.resources.configure_notification_permissions
@@ -37,6 +35,7 @@ import org.meshtastic.core.ui.icon.BatteryAlert
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Message
import org.meshtastic.core.ui.icon.Speaker
import org.meshtastic.core.ui.theme.AppTheme
/**
* Screen for configuring notification permissions during the app introduction. It explains why notification permissions
@@ -46,12 +45,17 @@ import org.meshtastic.core.ui.icon.Speaker
* 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.
* @param onOpenSettings Callback invoked when the user taps the settings link.
*/
@Composable
internal fun NotificationsScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfigure: () -> Unit) {
val context = LocalContext.current
internal fun NotificationsScreen(
showNextButton: Boolean,
onSkip: () -> Unit,
onConfigure: () -> Unit,
onOpenSettings: () -> Unit,
) {
val annotatedString =
context.createClickableAnnotatedString(
createClickableAnnotatedString(
fullTextRes = Res.string.notification_permissions_description,
linkTextRes = Res.string.settings,
tag = SETTINGS_TAG,
@@ -83,10 +87,14 @@ internal fun NotificationsScreen(showNextButton: Boolean, onSkip: () -> Unit, on
onSkip = onSkip,
onConfigure = onConfigure,
configureButtonTextRes = if (showNextButton) Res.string.next else Res.string.configure_notification_permissions,
onAnnotationClick = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", context.packageName, null)
context.startActivity(intent)
},
onAnnotationClick = { onOpenSettings() },
)
}
@PreviewLightDark
@Composable
private fun NotificationsScreenPreview() {
AppTheme {
Surface { NotificationsScreen(showNextButton = false, onSkip = {}, onConfigure = {}, onOpenSettings = {}) }
}
}
@@ -25,13 +25,14 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@@ -48,6 +49,7 @@ import org.meshtastic.core.ui.icon.Antenna
import org.meshtastic.core.ui.icon.MeshHub
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NearMe
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
/**
@@ -80,11 +82,11 @@ internal fun WelcomeScreen(onGetStarted: () -> Unit) {
Scaffold(
bottomBar = {
IntroBottomBar(
onSkip = {}, // No skip on welcome
onSkip = {},
onConfigure = onGetStarted,
skipButtonText = "", // Not shown
skipButtonText = "",
configureButtonText = stringResource(Res.string.get_started),
showSkipButton = false, // Explicitly hide skip for welcome
showSkipButton = false,
)
},
) { innerPadding ->
@@ -114,8 +116,8 @@ internal fun WelcomeScreen(onGetStarted: () -> Unit) {
}
}
@Preview
@PreviewLightDark
@Composable
private fun WelcomeScreenPreview() {
WelcomeScreen(onGetStarted = {})
AppTheme { Surface { WelcomeScreen(onGetStarted = {}) } }
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 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 org.meshtastic.feature.intro
/** JVM/Desktop stub: permissions are always granted (desktop doesn't need BLE/location onboarding). */
internal object JvmIntroPermissions : IntroPermissions {
private val grantedState =
object : IntroPermissionState {
override val isGranted: Boolean = true
override fun launchRequest() = Unit
}
override val bluetooth: IntroPermissionState = grantedState
override val location: IntroPermissionState = grantedState
override val notification: IntroPermissionState = grantedState
}
/** JVM/Desktop stub: settings navigation is a no-op. */
internal object JvmIntroSettingsNavigator : IntroSettingsNavigator {
override fun openAppSettings() = Unit
override fun openCriticalAlertsSettings() = Unit
}
@@ -27,12 +27,15 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.proto.Waypoint
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
@@ -46,6 +49,7 @@ class BaseMapViewModelTest {
private lateinit var viewModel: BaseMapViewModel
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var radioController: FakeRadioController
private lateinit var waypointPacketsFlow: MutableStateFlow<List<DataPacket>>
private val mapPrefs: MapPrefs = mock()
private val packetRepository: PacketRepository = mock()
@@ -62,7 +66,8 @@ class BaseMapViewModelTest {
every { mapPrefs.lastHeardFilter } returns MutableStateFlow(0L)
every { mapPrefs.lastHeardTrackFilter } returns MutableStateFlow(0L)
every { packetRepository.getWaypoints() } returns MutableStateFlow(emptyList())
waypointPacketsFlow = MutableStateFlow(emptyList())
every { packetRepository.getWaypoints() } returns waypointPacketsFlow
viewModel =
BaseMapViewModel(
@@ -121,4 +126,78 @@ class BaseMapViewModelTest {
assertEquals(3, nodeRepository.nodeDBbyNum.value.size)
}
@Test
fun testWaypointsIncludeFutureExpirations() = runTest(testDispatcher) {
val now = nowSeconds.toInt()
val futureWaypoint = waypointPacket(id = 1, expire = now + 60)
viewModel.waypoints.test {
assertEquals(emptyMap(), awaitItem())
waypointPacketsFlow.value = listOf(futureWaypoint)
assertEquals(mapOf(1 to futureWaypoint), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun testWaypointsExcludeBoundaryExpirations() = runTest(testDispatcher) {
val now = nowSeconds.toInt()
val expiredAtNowWaypoint = waypointPacket(id = 2, expire = now)
viewModel.waypoints.test {
assertEquals(emptyMap(), awaitItem())
waypointPacketsFlow.value = listOf(expiredAtNowWaypoint)
expectNoEvents()
assertEquals(emptyMap(), viewModel.waypoints.value)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun testWaypointsIncludeNeverExpiringWaypoints() = runTest(testDispatcher) {
val neverExpiresWaypoint = waypointPacket(id = 3, expire = 0)
viewModel.waypoints.test {
assertEquals(emptyMap(), awaitItem())
waypointPacketsFlow.value = listOf(neverExpiresWaypoint)
assertEquals(mapOf(3 to neverExpiresWaypoint), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun testWaypointsFilterMixedExpiredAndActiveWaypoints() = runTest(testDispatcher) {
val now = nowSeconds.toInt()
val expiredWaypoint = waypointPacket(id = 4, expire = now - 1)
val activeWaypoint = waypointPacket(id = 5, expire = now + 60)
val neverExpiresWaypoint = waypointPacket(id = 6, expire = 0)
viewModel.waypoints.test {
assertEquals(emptyMap(), awaitItem())
waypointPacketsFlow.value = listOf(expiredWaypoint, activeWaypoint, neverExpiresWaypoint)
assertEquals(
mapOf(
activeWaypoint.waypoint!!.id to activeWaypoint,
neverExpiresWaypoint.waypoint!!.id to neverExpiresWaypoint,
),
awaitItem(),
)
cancelAndIgnoreRemainingEvents()
}
}
private fun waypointPacket(id: Int, expire: Int): DataPacket = DataPacket(
to = DataPacket.ID_BROADCAST,
channel = 0,
waypoint = Waypoint(id = id, name = "Waypoint $id", expire = expire),
)
}
@@ -86,6 +86,7 @@ import org.meshtastic.core.resources.mute_1_week
import org.meshtastic.core.resources.mute_8_hours
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.mute_notifications
import org.meshtastic.core.resources.mute_selected
import org.meshtastic.core.resources.mute_status_always
import org.meshtastic.core.resources.mute_status_muted_for_days
import org.meshtastic.core.resources.mute_status_muted_for_hours
@@ -93,6 +94,7 @@ import org.meshtastic.core.resources.mute_status_unmuted
import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.select_all
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.resources.unmute_selected
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.MeshtasticImportFAB
@@ -464,11 +466,13 @@ private fun SelectionToolbar(
MeshtasticIcons.VolumeMute
},
contentDescription =
if (isAllMuted) {
"Unmute selected"
} else {
"Mute selected"
},
stringResource(
if (isAllMuted) {
Res.string.unmute_selected
} else {
Res.string.mute_selected
},
),
)
}
IconButton(onClick = onDeleteSelected) {
@@ -37,6 +37,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.download
import org.meshtastic.core.resources.firmware_version
import org.meshtastic.core.resources.view_release
import org.meshtastic.core.ui.icon.Download
import org.meshtastic.core.ui.icon.LinkIcon
@@ -52,7 +53,10 @@ fun FirmwareReleaseSheetContent(firmwareRelease: FirmwareRelease, modifier: Modi
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = firmwareRelease.title, style = MaterialTheme.typography.titleLarge)
Text(text = "Version: ${firmwareRelease.id}", style = MaterialTheme.typography.bodyMedium)
Text(
text = stringResource(Res.string.firmware_version, firmwareRelease.id),
style = MaterialTheme.typography.bodyMedium,
)
Markdown(modifier = Modifier.padding(8.dp), content = firmwareRelease.releaseNotes)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { openUrl(firmwareRelease.pageUrl) }, modifier = Modifier.weight(1f)) {
@@ -68,6 +68,10 @@ data class NodeDetailUiState(
val isEnsuringSession: Boolean = false,
)
internal object NodeDetailUiTextResolver {
var resolve: suspend (UiText) -> String = { it.resolve() }
}
/**
* ViewModel for the Node Details screen, coordinating data from the node database, mesh logs, and radio configuration.
*/
@@ -179,13 +183,15 @@ class NodeDetailViewModel(
EnsureSessionResult.Refreshed,
-> _navigationEvents.trySend(SettingsRoute.Settings(destNum))
EnsureSessionResult.Disconnected ->
snackbarManager.showSnackbar(
UiText.Resource(Res.string.connect_radio_for_remote_admin).resolve(),
)
EnsureSessionResult.Disconnected -> {
val text = Res.string.connect_radio_for_remote_admin
snackbarManager.showSnackbar(NodeDetailUiTextResolver.resolve(UiText.Resource(text)))
}
EnsureSessionResult.Timeout ->
snackbarManager.showSnackbar(UiText.Resource(Res.string.remote_admin_unreachable).resolve())
snackbarManager.showSnackbar(
NodeDetailUiTextResolver.resolve(UiText.Resource(Res.string.remote_admin_unreachable)),
)
}
} finally {
isEnsuringSession.value = false
@@ -84,6 +84,49 @@ internal val HOST_METRICS_INFO_DATA =
),
)
internal data class HostMetricsChartPoint(val time: Int, val value: Double)
internal data class HostMetricsChartData(
val load1: List<HostMetricsChartPoint> = emptyList(),
val load5: List<HostMetricsChartPoint> = emptyList(),
val load15: List<HostMetricsChartPoint> = emptyList(),
val freeMemoryMb: List<HostMetricsChartPoint> = emptyList(),
) {
val hasLoad: Boolean
get() = load1.isNotEmpty() || load5.isNotEmpty() || load15.isNotEmpty()
}
internal fun buildHostMetricsChartData(data: List<Telemetry>): HostMetricsChartData = HostMetricsChartData(
load1 =
data.mapNotNull { telemetry ->
telemetry.host_metrics
?.load1
?.takeIf { it > 0 }
?.let { HostMetricsChartPoint(time = telemetry.time, value = it / 100.0) }
},
load5 =
data.mapNotNull { telemetry ->
telemetry.host_metrics
?.load5
?.takeIf { it > 0 }
?.let { HostMetricsChartPoint(time = telemetry.time, value = it / 100.0) }
},
load15 =
data.mapNotNull { telemetry ->
telemetry.host_metrics
?.load15
?.takeIf { it > 0 }
?.let { HostMetricsChartPoint(time = telemetry.time, value = it / 100.0) }
},
freeMemoryMb =
data.mapNotNull { telemetry ->
telemetry.host_metrics
?.freemem_bytes
?.takeIf { it > 0 }
?.let { HostMetricsChartPoint(time = telemetry.time, value = it.toDouble() / BYTES_IN_MB) }
},
)
/**
* Vico chart composable that renders load averages (1m, 5m, 15m) and free memory as dual-axis line series: load on the
* start axis (fixed min 0), free memory in MB on the end axis.
@@ -103,41 +146,29 @@ internal fun HostMetricsChart(
modelProducer,
chartModifier,
->
val load1Data = remember(data) { data.filter { it.host_metrics?.load1 != null && it.host_metrics!!.load1 > 0 } }
val load5Data = remember(data) { data.filter { it.host_metrics?.load5 != null && it.host_metrics!!.load5 > 0 } }
val load15Data =
remember(data) { data.filter { it.host_metrics?.load15 != null && it.host_metrics!!.load15 > 0 } }
val memData =
remember(data) {
data.filter { it.host_metrics?.freemem_bytes != null && it.host_metrics!!.freemem_bytes > 0 }
}
val chartData = remember(data) { buildHostMetricsChartData(data) }
val load1Data = chartData.load1
val load5Data = chartData.load5
val load15Data = chartData.load15
val memData = chartData.freeMemoryMb
LaunchedEffect(load1Data, load5Data, load15Data, memData) {
LaunchedEffect(chartData) {
modelProducer.runTransaction {
val hasLoad = load1Data.isNotEmpty() || load5Data.isNotEmpty() || load15Data.isNotEmpty()
if (hasLoad) {
if (chartData.hasLoad) {
lineSeries {
if (load1Data.isNotEmpty()) {
series(x = load1Data.map { it.time }, y = load1Data.map { it.host_metrics!!.load1 / 100.0 })
series(x = load1Data.map { it.time }, y = load1Data.map { it.value })
}
if (load5Data.isNotEmpty()) {
series(x = load5Data.map { it.time }, y = load5Data.map { it.host_metrics!!.load5 / 100.0 })
series(x = load5Data.map { it.time }, y = load5Data.map { it.value })
}
if (load15Data.isNotEmpty()) {
series(
x = load15Data.map { it.time },
y = load15Data.map { it.host_metrics!!.load15 / 100.0 },
)
series(x = load15Data.map { it.time }, y = load15Data.map { it.value })
}
}
}
if (memData.isNotEmpty()) {
lineSeries {
series(
x = memData.map { it.time },
y = memData.map { it.host_metrics!!.freemem_bytes.toDouble() / BYTES_IN_MB },
)
}
lineSeries { series(x = memData.map { it.time }, y = memData.map { it.value }) }
}
}
}
@@ -160,7 +191,7 @@ internal fun HostMetricsChart(
},
)
val hasLoad = load1Data.isNotEmpty() || load5Data.isNotEmpty() || load15Data.isNotEmpty()
val hasLoad = chartData.hasLoad
val load1Style = if (load1Data.isNotEmpty()) ChartStyling.createStyledLine(load1Color) else null
val load5Style = if (load5Data.isNotEmpty()) ChartStyling.createDashedLine(load5Color) else null
val load15Style = if (load15Data.isNotEmpty()) ChartStyling.createSubtleLine(load15Color) else null
@@ -42,6 +42,7 @@ import org.meshtastic.core.model.getNeighborInfoResponse
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.routing_error_no_response
import org.meshtastic.core.resources.success
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.Groups
import org.meshtastic.core.ui.icon.MeshtasticIcons
@@ -102,7 +103,12 @@ fun NeighborInfoLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewM
}
val time = DateFormatter.formatDateTime(log.received_date)
val text = if (result != null) "Success" else stringResource(Res.string.routing_error_no_response)
val text =
if (result != null) {
stringResource(Res.string.success)
} else {
stringResource(Res.string.routing_error_no_response)
}
val icon = if (result != null) MeshtasticIcons.Groups else MeshtasticIcons.PersonOff
val header = stringResource(Res.string.neighbor_info)
var expanded by remember { mutableStateOf(false) }
@@ -24,8 +24,10 @@ import dev.mokkery.mock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.di.CoroutineDispatchers
@@ -37,6 +39,7 @@ import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
@@ -136,4 +139,144 @@ class CompassViewModelTest {
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `uiState uses PDOP only positional accuracy`() = runTest {
val state =
startAndGetUiState(
targetPosition =
org.meshtastic.proto.Position(
latitude_i = 10000000,
longitude_i = 10010000,
time = 1,
gps_accuracy = 5000,
PDOP = 250,
),
)
assertEquals("12 m", state.errorRadiusText)
}
@Test
fun `uiState uses HDOP and VDOP positional accuracy when PDOP is missing`() = runTest {
val state =
startAndGetUiState(
targetPosition =
org.meshtastic.proto.Position(
latitude_i = 10000000,
longitude_i = 10010000,
time = 1,
gps_accuracy = 5000,
HDOP = 300,
VDOP = 400,
),
)
assertEquals("25 m", state.errorRadiusText)
}
@Test
fun `uiState uses HDOP only positional accuracy when VDOP is missing`() = runTest {
val state =
startAndGetUiState(
targetPosition =
org.meshtastic.proto.Position(
latitude_i = 10000000,
longitude_i = 10010000,
time = 1,
gps_accuracy = 5000,
HDOP = 175,
),
)
assertEquals("8 m", state.errorRadiusText)
}
@Test
fun `uiState falls back to precision bits positional accuracy`() = runTest {
val state =
startAndGetUiState(
targetPosition =
org.meshtastic.proto.Position(
latitude_i = 10000000,
longitude_i = 10010000,
time = 1,
precision_bits = 15,
),
)
assertEquals("729 m", state.errorRadiusText)
}
@Test
fun `uiState leaves positional accuracy empty when no DOP or precision data exists`() = runTest {
val state =
startAndGetUiState(
targetPosition =
org.meshtastic.proto.Position(
latitude_i = 10000000,
longitude_i = 10010000,
time = 1,
gps_accuracy = 5000,
),
)
assertNull(state.errorRadiusText)
assertNull(state.angularErrorDeg)
}
@Test
fun `uiState returns 180 degree angular error when distance is zero`() = runTest {
val state =
startAndGetUiState(
targetPosition =
org.meshtastic.proto.Position(
latitude_i = 10000000,
longitude_i = 10000000,
time = 1,
gps_accuracy = 5000,
PDOP = 250,
),
location = PhoneLocation(1.0, 1.0, 0.0, 1000L),
)
assertEquals("12 m", state.errorRadiusText)
assertNotNull(state.angularErrorDeg)
assertEquals(180f, state.angularErrorDeg)
}
@Test
fun `uiState handles angular error for very small distances`() = runTest {
val state =
startAndGetUiState(
targetPosition =
org.meshtastic.proto.Position(
latitude_i = 10000100,
longitude_i = 10000000,
time = 1,
gps_accuracy = 5000,
PDOP = 250,
),
location = PhoneLocation(1.0, 1.0, 0.0, 1000L),
)
assertEquals("12 m", state.errorRadiusText)
assertNotNull(state.angularErrorDeg)
assertEquals(85.4f, state.angularErrorDeg, 0.5f)
}
private suspend fun TestScope.startAndGetUiState(
targetPosition: org.meshtastic.proto.Position,
location: PhoneLocation = PhoneLocation(1.0, 1.0, 0.0, 1000L),
): CompassUiState {
viewModel.start(
Node(num = 1234, user = User(id = "!1234"), position = targetPosition),
Config.DisplayConfig.DisplayUnits.METRIC,
)
locationFlow.value = PhoneLocationState(permissionGranted = true, providerEnabled = true, location = location)
runCurrent()
return viewModel.uiState.value
}
}
@@ -16,13 +16,16 @@
*/
package org.meshtastic.feature.node.detail
import androidx.compose.material3.SnackbarDuration
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -30,13 +33,20 @@ import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCase
import org.meshtastic.core.domain.usecase.session.EnsureSessionResult
import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.connect_radio_for_remote_admin
import org.meshtastic.core.resources.remote_admin_unreachable
import org.meshtastic.core.ui.util.SnackbarManager
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
@@ -58,7 +68,7 @@ class NodeDetailViewModelTest {
private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock()
private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase = mock()
private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase = mock()
private val snackbarManager: SnackbarManager = mock()
private val snackbarManager = RecordingSnackbarManager()
@BeforeTest
fun setUp() {
@@ -66,6 +76,19 @@ class NodeDetailViewModelTest {
every { getNodeDetailsUseCase(any()) } returns emptyFlow()
every { observeRemoteAdminSessionStatus(any()) } returns flowOf(SessionStatus.NoSession)
snackbarManager.messages.clear()
NodeDetailUiTextResolver.resolve = { text ->
when (text) {
is UiText.DynamicString -> text.value
is UiText.Resource ->
when (text.res) {
Res.string.connect_radio_for_remote_admin -> "Connect to a radio to administer remote nodes."
Res.string.remote_admin_unreachable -> "Could not reach node — try again or move closer."
else -> error("Unexpected UiText resource in test: ${text.res}")
}
}
}
viewModel = createViewModel(1234)
}
@@ -81,8 +104,23 @@ class NodeDetailViewModelTest {
snackbarManager = snackbarManager,
)
private class RecordingSnackbarManager : SnackbarManager() {
val messages = mutableListOf<String>()
override fun showSnackbar(
message: String,
actionLabel: String?,
withDismissAction: Boolean,
duration: SnackbarDuration,
onAction: (() -> Unit)?,
) {
messages += message
}
}
@AfterTest
fun tearDown() {
NodeDetailUiTextResolver.resolve = { it.resolve() }
Dispatchers.resetMain()
}
@@ -126,4 +164,42 @@ class NodeDetailViewModelTest {
verify { nodeRequestActions.requestTraceroute(any(), 1234, "Test Node") }
}
@Test
fun `openRemoteAdmin navigates to settings when session is already active`() = runTest(testDispatcher) {
everySuspend { ensureRemoteAdminSession(1234) } returns EnsureSessionResult.AlreadyActive
viewModel.navigationEvents.test {
viewModel.openRemoteAdmin(1234)
assertEquals(SettingsRoute.Settings(1234), awaitItem())
cancelAndIgnoreRemainingEvents()
}
verifySuspend { ensureRemoteAdminSession(1234) }
}
@Test
fun `openRemoteAdmin shows disconnected snackbar when radio is disconnected`() = runTest(testDispatcher) {
everySuspend { ensureRemoteAdminSession(1234) } returns EnsureSessionResult.Disconnected
val expectedMessage = "Connect to a radio to administer remote nodes."
viewModel.openRemoteAdmin(1234)
runCurrent()
assertEquals(listOf(expectedMessage), snackbarManager.messages)
verifySuspend { ensureRemoteAdminSession(1234) }
}
@Test
fun `openRemoteAdmin shows timeout snackbar when node is unreachable`() = runTest(testDispatcher) {
everySuspend { ensureRemoteAdminSession(1234) } returns EnsureSessionResult.Timeout
val expectedMessage = "Could not reach node — try again or move closer."
viewModel.openRemoteAdmin(1234)
runCurrent()
assertEquals(listOf(expectedMessage), snackbarManager.messages)
verifySuspend { ensureRemoteAdminSession(1234) }
}
}
@@ -227,6 +227,14 @@ class EnvironmentMetricsForGraphingTest {
assertFalse(result.shouldPlot[Environment.TEMPERATURE.ordinal])
}
@Test
fun nanHumidity_filteredOut() {
val metrics = listOf(telemetry(env = EnvironmentMetrics(relative_humidity = Float.NaN)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertFalse(result.shouldPlot[Environment.HUMIDITY.ordinal])
}
@Test
fun nanPressure_filteredOut() {
val metrics = listOf(telemetry(env = EnvironmentMetrics(barometric_pressure = Float.NaN)))
@@ -237,6 +245,71 @@ class EnvironmentMetricsForGraphingTest {
assertEquals(0f, result.leftMinMax.second, 0.01f)
}
@Test
fun mixedValidAndNanValues_onlyValidEnvironmentMetricsAreCharted() {
val metrics =
listOf(
telemetry(
env =
EnvironmentMetrics(
temperature = Float.NaN,
relative_humidity = 50f,
barometric_pressure = Float.NaN,
),
),
telemetry(
env =
EnvironmentMetrics(
temperature = 20f,
relative_humidity = Float.NaN,
barometric_pressure = 1015f,
),
),
telemetry(
env = EnvironmentMetrics(temperature = 25f, relative_humidity = 60f, barometric_pressure = 1020f),
),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal])
assertTrue(result.shouldPlot[Environment.HUMIDITY.ordinal])
assertTrue(result.shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal])
assertEquals(1015f, result.leftMinMax.first, 0.01f)
assertEquals(1020f, result.leftMinMax.second, 0.01f)
assertEquals(20f, result.rightMinMax.first, 0.01f)
assertEquals(60f, result.rightMinMax.second, 0.01f)
}
@Test
fun allNanValues_returnDefaultAxesAndNoPlots() {
val metrics =
listOf(
telemetry(
env =
EnvironmentMetrics(
temperature = Float.NaN,
relative_humidity = Float.NaN,
barometric_pressure = Float.NaN,
),
),
telemetry(
env =
EnvironmentMetrics(
temperature = Float.NaN,
relative_humidity = Float.NaN,
barometric_pressure = Float.NaN,
),
),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot.none { it })
assertEquals(0f, result.leftMinMax.first, 0.01f)
assertEquals(0f, result.leftMinMax.second, 0.01f)
assertEquals(0f, result.rightMinMax.first, 0.01f)
assertEquals(1f, result.rightMinMax.second, 0.01f)
}
// ---- Multiple metrics combined ----
@Test
@@ -46,6 +46,11 @@ class FormatBytesTest {
assertEquals("1.5 KB", formatBytes(1536L))
}
@Test
fun kilobytes_just_below_megabyte_boundary_round_up_without_switching_units() {
assertEquals("1024 KB", formatBytes(1_048_575L))
}
@Test
fun megabyte_boundary() {
assertEquals("1 MB", formatBytes(1024L * 1024))
@@ -91,4 +96,10 @@ class FormatBytesTest {
// 1536 bytes = 1.5 KB, with 1 decimal place → 1.5 KB
assertEquals("1.5 KB", formatBytes(1536L, decimalPlaces = 1))
}
@Test
fun default_rounding_keeps_two_decimal_places_without_trailing_zeroes() {
assertEquals("1.46 KB", formatBytes(1500L))
assertEquals("1.5 KB", formatBytes(1536L))
}
}
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2026 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 org.meshtastic.feature.node.metrics
import org.meshtastic.proto.HostMetrics
import org.meshtastic.proto.Telemetry
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@Suppress("MagicNumber")
class HostMetricsTest {
private fun telemetry(time: Int, hostMetrics: HostMetrics? = null) =
Telemetry(time = time, host_metrics = hostMetrics)
@Test
fun buildHostMetricsChartData_filters_missing_and_non_positive_values() {
val chartData =
buildHostMetricsChartData(
listOf(
telemetry(
time = 100,
hostMetrics = HostMetrics(load1 = 150, load5 = 0, load15 = 225, freemem_bytes = 2_097_152L),
),
telemetry(time = 200, hostMetrics = HostMetrics(load1 = 0, load5 = 320, freemem_bytes = 0L)),
telemetry(time = 300, hostMetrics = null),
),
)
assertTrue(chartData.hasLoad)
assertEquals(listOf(HostMetricsChartPoint(time = 100, value = 1.5)), chartData.load1)
assertEquals(listOf(HostMetricsChartPoint(time = 200, value = 3.2)), chartData.load5)
assertEquals(listOf(HostMetricsChartPoint(time = 100, value = 2.25)), chartData.load15)
assertEquals(listOf(HostMetricsChartPoint(time = 100, value = 2.0)), chartData.freeMemoryMb)
}
@Test
fun buildHostMetricsChartData_returns_empty_series_when_no_plottable_metrics_exist() {
val chartData =
buildHostMetricsChartData(
listOf(
telemetry(
time = 100,
hostMetrics = HostMetrics(load1 = 0, load5 = 0, load15 = 0, freemem_bytes = 0L),
),
telemetry(time = 200, hostMetrics = HostMetrics()),
telemetry(time = 300, hostMetrics = null),
),
)
assertFalse(chartData.hasLoad)
assertTrue(chartData.load1.isEmpty())
assertTrue(chartData.load5.isEmpty())
assertTrue(chartData.load15.isEmpty())
assertTrue(chartData.freeMemoryMb.isEmpty())
}
}
@@ -47,7 +47,11 @@ import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Position
import org.meshtastic.proto.PowerMetrics
import org.meshtastic.proto.Telemetry
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
@@ -225,4 +229,203 @@ class MetricsViewModelTest {
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `saveDeviceMetricsCSV writes correct data`() = runTest(testDispatcher) {
val testTelemetry =
Telemetry(
time = 1700000000,
device_metrics =
DeviceMetrics(
battery_level = 80,
voltage = 4.1f,
channel_utilization = 12.5f,
air_util_tx = 3.25f,
uptime_seconds = 3600,
),
)
val nodeDetailFlow =
MutableStateFlow(NodeDetailUiState(metricsState = MetricsState(deviceMetrics = listOf(testTelemetry))))
every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow()
val buffer = Buffer()
everySuspend { fileService.write(any(), any()) } calls
{ args ->
val block = args.arg<suspend (BufferedSink) -> Unit>(1)
block(buffer)
true
}
val vm = createViewModel()
vm.state.test {
awaitItem()
awaitItem()
val uri = CommonUri.parse("content://test")
vm.saveDeviceMetricsCSV(uri, listOf(testTelemetry))
runCurrent()
verifySuspend { fileService.write(uri, any()) }
val csvOutput = buffer.readUtf8()
assertTrue(
csvOutput.startsWith(
"\"date\",\"time\",\"batteryLevel\",\"voltage\",\"channelUtilization\",\"airUtilTx\",\"uptimeSeconds\"",
),
)
assertTrue(csvOutput.contains("\"80\",\"4.1\",\"12.5\",\"3.25\",\"3600\""))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `saveEnvironmentMetricsCSV writes correct data`() = runTest(testDispatcher) {
val testTelemetry =
Telemetry(
time = 1700000000,
environment_metrics =
EnvironmentMetrics(
temperature = 21.5f,
relative_humidity = 55.5f,
barometric_pressure = 1013.25f,
gas_resistance = 12.3f,
iaq = 42,
wind_speed = 5.5f,
wind_direction = 180,
soil_temperature = 18.75f,
soil_moisture = 65,
one_wire_temperature = listOf(1f, 2f, 3f),
),
)
val nodeDetailFlow =
MutableStateFlow(
NodeDetailUiState(
metricsState = MetricsState(deviceMetrics = emptyList()),
environmentState = EnvironmentMetricsState(environmentMetrics = listOf(testTelemetry)),
),
)
every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow()
val buffer = Buffer()
everySuspend { fileService.write(any(), any()) } calls
{ args ->
val block = args.arg<suspend (BufferedSink) -> Unit>(1)
block(buffer)
true
}
val vm = createViewModel()
vm.state.test {
awaitItem()
val uri = CommonUri.parse("content://test")
vm.saveEnvironmentMetricsCSV(uri, listOf(testTelemetry))
runCurrent()
verifySuspend { fileService.write(uri, any()) }
val csvOutput = buffer.readUtf8()
assertTrue(
csvOutput.startsWith(
"\"date\",\"time\",\"temperature\",\"relativeHumidity\",\"barometricPressure\",\"gasResistance\",\"iaq\",\"windSpeed\",\"windDirection\",\"soilTemperature\",\"soilMoisture\",\"oneWireTemp1\",\"oneWireTemp2\",\"oneWireTemp3\",\"oneWireTemp4\",\"oneWireTemp5\",\"oneWireTemp6\",\"oneWireTemp7\",\"oneWireTemp8\"",
),
)
assertTrue(
csvOutput.contains(
"\"21.5\",\"55.5\",\"1013.25\",\"12.3\",\"42\",\"5.5\",\"180\",\"18.75\",\"65\",\"1.0\",\"2.0\",\"3.0\",\"\",\"\",\"\",\"\",\"\"",
),
)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `saveSignalMetricsCSV writes correct data`() = runTest(testDispatcher) {
val testPacket = MeshPacket(rx_time = 1700000000, rx_rssi = -105, rx_snr = 7.5f)
val nodeDetailFlow =
MutableStateFlow(NodeDetailUiState(metricsState = MetricsState(signalMetrics = listOf(testPacket))))
every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow()
val buffer = Buffer()
everySuspend { fileService.write(any(), any()) } calls
{ args ->
val block = args.arg<suspend (BufferedSink) -> Unit>(1)
block(buffer)
true
}
val vm = createViewModel()
vm.state.test {
awaitItem()
awaitItem()
val uri = CommonUri.parse("content://test")
vm.saveSignalMetricsCSV(uri, listOf(testPacket))
runCurrent()
verifySuspend { fileService.write(uri, any()) }
val csvOutput = buffer.readUtf8()
assertTrue(csvOutput.startsWith("\"date\",\"time\",\"rssi\",\"snr\""))
assertTrue(csvOutput.contains("\"-105\",\"7.5\""))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `savePowerMetricsCSV writes correct data`() = runTest(testDispatcher) {
val testTelemetry =
Telemetry(
time = 1700000000,
power_metrics =
PowerMetrics(
ch1_voltage = 3.3f,
ch1_current = 0.1f,
ch2_voltage = 5.0f,
ch2_current = 0.2f,
ch3_voltage = 12.0f,
ch3_current = 0.3f,
),
)
val nodeDetailFlow =
MutableStateFlow(NodeDetailUiState(metricsState = MetricsState(powerMetrics = listOf(testTelemetry))))
every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow()
val buffer = Buffer()
everySuspend { fileService.write(any(), any()) } calls
{ args ->
val block = args.arg<suspend (BufferedSink) -> Unit>(1)
block(buffer)
true
}
val vm = createViewModel()
vm.state.test {
awaitItem()
awaitItem()
val uri = CommonUri.parse("content://test")
vm.savePowerMetricsCSV(uri, listOf(testTelemetry))
runCurrent()
verifySuspend { fileService.write(uri, any()) }
val csvOutput = buffer.readUtf8()
assertTrue(
csvOutput.startsWith(
"\"date\",\"time\",\"ch1Voltage\",\"ch1Current\",\"ch2Voltage\",\"ch2Current\",\"ch3Voltage\",\"ch3Current\"",
),
)
assertTrue(csvOutput.contains("\"3.3\",\"0.1\",\"5.0\",\"0.2\",\"12.0\",\"0.3\""))
cancelAndIgnoreRemainingEvents()
}
}
}
@@ -18,9 +18,13 @@ package org.meshtastic.feature.settings
import app.cash.turbine.test
import dev.mokkery.MockMode
import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verifySuspend
import io.kotest.matchers.ints.shouldBeInRange
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
@@ -35,7 +39,11 @@ import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okio.Buffer
import okio.BufferedSink
import okio.ByteString.Companion.encodeUtf8
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
@@ -47,6 +55,7 @@ import org.meshtastic.core.domain.usecase.settings.SetNotificationSettingsUseCas
import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.testing.FakeAppPreferences
@@ -56,12 +65,18 @@ import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeNotificationPrefs
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.proto.Data
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class SettingsViewModelTest {
@@ -255,6 +270,81 @@ class SettingsViewModelTest {
appPreferences.ui.shouldProvideNodeLocation(myNodeNum).value shouldBe false
}
@Test
fun `saveDataCsv writes filtered export via file service`() = runTest {
val myNodeNum = 456
val senderNodeNum = 123
nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(myNodeNum = myNodeNum))
nodeRepository.setNodes(
listOf(TestDataFactory.createTestNode(num = senderNodeNum, longName = "Sender Node", shortName = "SN")),
)
meshLogRepository.setLogs(
listOf(
MeshLog(
uuid = "match",
message_type = "TEXT",
received_date = 1_700_000_000_000,
raw_message = "",
fromNum = senderNodeNum,
portNum = PortNum.TEXT_MESSAGE_APP.value,
fromRadio =
FromRadio(
packet =
MeshPacket(
from = senderNodeNum,
rx_snr = 5.0f,
decoded =
Data(
portnum = PortNum.TEXT_MESSAGE_APP,
payload = "Hello settings".encodeUtf8(),
),
),
),
),
MeshLog(
uuid = "filtered-out",
message_type = "RANGE",
received_date = 1_700_000_001_000,
raw_message = "",
fromNum = senderNodeNum,
portNum = PortNum.RANGE_TEST_APP.value,
fromRadio =
FromRadio(
packet =
MeshPacket(
from = senderNodeNum,
rx_snr = 6.0f,
decoded = Data(
portnum = PortNum.RANGE_TEST_APP,
payload = "Ignore me".encodeUtf8(),
),
),
),
),
),
)
val buffer = Buffer()
everySuspend { fileService.write(any(), any()) } calls
{ args ->
val block = args.arg<suspend (BufferedSink) -> Unit>(1)
block(buffer)
true
}
val uri = CommonUri.parse("content://test/export.csv")
viewModel.saveDataCsv(uri, filterPortnum = PortNum.TEXT_MESSAGE_APP.value)
runCurrent()
verifySuspend { fileService.write(uri, any()) }
val csvOutput = buffer.readUtf8()
assertTrue(csvOutput.startsWith("\"date\",\"time\",\"from\""))
assertTrue(csvOutput.contains("\"123\",\"Sender Node\""))
assertTrue(csvOutput.contains("Hello settings"))
assertFalse(csvOutput.contains("Ignore me"))
}
@Test
fun `setDbCacheLimit updates manager`() = runTest {
viewModel.setDbCacheLimit(200)
@@ -0,0 +1,254 @@
/*
* Copyright (c) 2026 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 org.meshtastic.feature.settings.radio
import androidx.lifecycle.SavedStateHandle
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okio.Buffer
import okio.BufferedSink
import okio.BufferedSource
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.AnalyticsPrefs
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.LocationRepository
import org.meshtastic.core.repository.LocationService
import org.meshtastic.core.repository.MapConsentPrefs
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.Position
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@OptIn(ExperimentalCoroutinesApi::class)
class ProfileRoundTripTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
private val packetRepository: PacketRepository = mock(MockMode.autofill)
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private val nodeRepository = FakeNodeRepository()
private val locationRepository: LocationRepository = mock(MockMode.autofill)
private val mapConsentPrefs: MapConsentPrefs = mock(MockMode.autofill)
private val analyticsPrefs: AnalyticsPrefs = mock(MockMode.autofill)
private val homoglyphEncodingPrefs: HomoglyphPrefs = mock(MockMode.autofill)
private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase = mock(MockMode.autofill)
private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase = mock(MockMode.autofill)
private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase = mock(MockMode.autofill)
private val installProfileUseCase: InstallProfileUseCase = mock(MockMode.autofill)
private val radioConfigUseCase: RadioConfigUseCase = mock(MockMode.autofill)
private val adminActionsUseCase: AdminActionsUseCase = mock(MockMode.autofill)
private val processRadioResponseUseCase: ProcessRadioResponseUseCase = mock(MockMode.autofill)
private val locationService: LocationService = mock(MockMode.autofill)
private val mqttManager: MqttManager = mock(MockMode.autofill)
private lateinit var fileService: InMemoryFileService
private lateinit var viewModel: RadioConfigViewModel
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
fileService = InMemoryFileService()
every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(DeviceProfile())
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig())
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
every { radioConfigRepository.moduleConfigFlow } returns MutableStateFlow(LocalModuleConfig())
every { radioConfigRepository.deviceUIConfigFlow } returns MutableStateFlow(null)
every { radioConfigRepository.fileManifestFlow } returns MutableStateFlow(emptyList())
every { analyticsPrefs.analyticsAllowed } returns MutableStateFlow(false)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(false)
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow<MeshPacket>()
every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Connected)
every { mqttManager.mqttConnectionState } returns
MutableStateFlow(org.meshtastic.core.model.MqttConnectionState.Inactive)
viewModel =
RadioConfigViewModel(
savedStateHandle = SavedStateHandle(),
radioConfigRepository = radioConfigRepository,
packetRepository = packetRepository,
serviceRepository = serviceRepository,
nodeRepository = nodeRepository,
locationRepository = locationRepository,
mapConsentPrefs = mapConsentPrefs,
analyticsPrefs = analyticsPrefs,
homoglyphEncodingPrefs = homoglyphEncodingPrefs,
toggleAnalyticsUseCase = toggleAnalyticsUseCase,
toggleHomoglyphEncodingUseCase = toggleHomoglyphEncodingUseCase,
importProfileUseCase = ImportProfileUseCase(),
exportProfileUseCase = ExportProfileUseCase(),
exportSecurityConfigUseCase = exportSecurityConfigUseCase,
installProfileUseCase = installProfileUseCase,
radioConfigUseCase = radioConfigUseCase,
adminActionsUseCase = adminActionsUseCase,
processRadioResponseUseCase = processRadioResponseUseCase,
locationService = locationService,
fileService = fileService,
mqttManager = mqttManager,
)
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `profile export then import round trips representative DeviceProfile`() = runTest {
assertRoundTrip(
DeviceProfile(
long_name = "Round Trip Node",
short_name = "RTN",
channel_url = "https://meshtastic.org/e/#CgMSAQESBggBQANIAQ",
config =
LocalConfig(
device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER, button_gpio = 7),
lora = Config.LoRaConfig(hop_limit = 5, use_preset = true),
power = Config.PowerConfig(is_power_saving = true, ls_secs = 300),
network =
Config.NetworkConfig(
wifi_enabled = true,
wifi_ssid = "mesh-ssid",
wifi_psk = "mesh-pass",
ntp_server = "meshtastic.pool.ntp.org",
),
),
module_config =
LocalModuleConfig(
mqtt =
ModuleConfig.MQTTConfig(
enabled = true,
proxy_to_client_enabled = true,
root = "msh/US/test",
json_enabled = true,
),
telemetry =
ModuleConfig.TelemetryConfig(
device_update_interval = 300,
environment_measurement_enabled = true,
power_measurement_enabled = true,
),
canned_message =
ModuleConfig.CannedMessageConfig(
rotary1_enabled = true,
inputbroker_pin_a = 12,
inputbroker_pin_b = 13,
send_bell = true,
),
statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Ready to mesh"),
),
fixed_position = Position(latitude_i = 327766650, longitude_i = -967969890, altitude = 138),
ringtone = "tones/notify.mp3",
canned_messages = "Alpha|Bravo|Charlie",
),
)
}
@Test
fun `profile export then import round trips empty DeviceProfile`() = runTest { assertRoundTrip(DeviceProfile()) }
@Test
fun `profile export then import round trips partially populated DeviceProfile`() = runTest {
assertRoundTrip(
DeviceProfile(
long_name = "Partial Node",
module_config =
LocalModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Standing by")),
),
)
}
private suspend fun TestScope.assertRoundTrip(profile: DeviceProfile) {
val exportUri = CommonUri.parse("content://test/profile.bin")
val reExportUri = CommonUri.parse("content://test/profile-reexport.bin")
var importedProfile: DeviceProfile? = null
viewModel.exportProfile(exportUri, profile)
runCurrent()
viewModel.importProfile(exportUri) { importedProfile = it }
runCurrent()
val actualImportedProfile = assertNotNull(importedProfile)
assertEquals(profile, actualImportedProfile)
assertContentEquals(profile.encode(), fileService.readBytes(exportUri))
assertContentEquals(profile.encode(), actualImportedProfile.encode())
viewModel.exportProfile(reExportUri, actualImportedProfile)
runCurrent()
assertContentEquals(fileService.readBytes(exportUri), fileService.readBytes(reExportUri))
}
private class InMemoryFileService : FileService {
private val files = mutableMapOf<String, ByteArray>()
override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean {
val buffer = Buffer()
block(buffer)
files[uri.toString()] = buffer.readByteArray()
return true
}
override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean {
val bytes = files[uri.toString()] ?: return false
block(Buffer().write(bytes))
return true
}
fun readBytes(uri: CommonUri): ByteArray = files[uri.toString()] ?: error("Missing file for $uri")
}
}
@@ -19,6 +19,7 @@ package org.meshtastic.feature.settings.radio
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import dev.mokkery.MockMode
import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
@@ -28,6 +29,7 @@ import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -46,6 +48,7 @@ import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
import org.meshtastic.core.model.MqttProbeStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.AnalyticsPrefs
import org.meshtastic.core.repository.FileService
@@ -221,6 +224,68 @@ class RadioConfigViewModelTest {
}
}
@Test
fun `probeMqttConnection updates status for success`() = runTest {
everySuspend { mqttManager.probe("mqtt.example.com", true, "user", "pass") }
.calls {
delay(1)
MqttProbeStatus.Success(serverInfo = "client=test")
}
viewModel.probeMqttConnection("mqtt.example.com", true, "user", "pass")
assertEquals(MqttProbeStatus.Probing, viewModel.mqttProbeStatus.value)
advanceTimeBy(1)
runCurrent()
assertEquals(MqttProbeStatus.Success(serverInfo = "client=test"), viewModel.mqttProbeStatus.value)
verifySuspend { mqttManager.probe("mqtt.example.com", true, "user", "pass") }
}
@Test
fun `probeMqttConnection updates status for timeout`() = runTest {
everySuspend { mqttManager.probe("mqtt.example.com", false, null, null) } returns MqttProbeStatus.Timeout(5_000)
viewModel.probeMqttConnection("mqtt.example.com", false, null, null)
runCurrent()
assertEquals(MqttProbeStatus.Timeout(5_000), viewModel.mqttProbeStatus.value)
verifySuspend { mqttManager.probe("mqtt.example.com", false, null, null) }
}
@Test
fun `probeMqttConnection converts thrown exception to other status`() = runTest {
everySuspend { mqttManager.probe("mqtt.example.com", true, null, null) }
.calls { throw IllegalStateException("boom") }
viewModel.probeMqttConnection("mqtt.example.com", true, null, null)
runCurrent()
assertEquals(MqttProbeStatus.Other(message = "boom"), viewModel.mqttProbeStatus.value)
verifySuspend { mqttManager.probe("mqtt.example.com", true, null, null) }
}
@Test
fun `clearMqttProbeStatus resets probe state`() = runTest {
everySuspend { mqttManager.probe("mqtt.example.com", false, null, null) }
.calls {
delay(1)
MqttProbeStatus.Success(serverInfo = "client=test")
}
viewModel.probeMqttConnection("mqtt.example.com", false, null, null)
assertEquals(MqttProbeStatus.Probing, viewModel.mqttProbeStatus.value)
viewModel.clearMqttProbeStatus()
assertEquals(null, viewModel.mqttProbeStatus.value)
advanceTimeBy(1)
runCurrent()
assertEquals(null, viewModel.mqttProbeStatus.value)
}
@Test
fun `updateChannels calls useCase for each changed channel`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
@@ -169,7 +169,7 @@ class WifiProvisionViewModel(
* @param ssid The target network SSID.
* @param password The network password (empty string for open networks).
*/
fun provisionWifi(ssid: String, password: String) {
fun provisionWifi(ssid: String, password: String, hidden: Boolean = false) {
if (ssid.isBlank()) return
val nymeaService = service ?: return
@@ -183,7 +183,7 @@ class WifiProvisionViewModel(
}
viewModelScope.launch {
when (val result = nymeaService.provision(ssid, password)) {
when (val result = nymeaService.provision(ssid, password, hidden)) {
is ProvisionResult.Success -> {
Logger.i { "$TAG: Provisioned successfully" }
_uiState.update {
@@ -71,7 +71,7 @@ private val manyNetworks =
}
private val noOp: () -> Unit = {}
private val noOpProvision: (String, String) -> Unit = { _, _ -> }
private val noOpProvision: (String, String, Boolean) -> Unit = { _, _, _ -> }
// ---------------------------------------------------------------------------
// Phase 1: BLE scanning
@@ -66,6 +66,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -101,12 +102,14 @@ import org.meshtastic.core.resources.hide_password
import org.meshtastic.core.resources.img_mpwrd_logo
import org.meshtastic.core.resources.mpwrd_os
import org.meshtastic.core.resources.password
import org.meshtastic.core.resources.retry
import org.meshtastic.core.resources.show_password
import org.meshtastic.core.resources.wifi_provision_available_networks
import org.meshtastic.core.resources.wifi_provision_connect_failed
import org.meshtastic.core.resources.wifi_provision_description
import org.meshtastic.core.resources.wifi_provision_device_found
import org.meshtastic.core.resources.wifi_provision_device_found_detail
import org.meshtastic.core.resources.wifi_provision_hidden_network
import org.meshtastic.core.resources.wifi_provision_mpwrd_disclaimer
import org.meshtastic.core.resources.wifi_provision_no_networks
import org.meshtastic.core.resources.wifi_provision_scan_failed
@@ -206,7 +209,11 @@ fun WifiProvisionScreen(
Crossfade(targetState = screenKey(uiState), label = "wifi_provision") { key ->
when (key) {
ScreenKey.ConnectingBle -> ScanningBleContent()
ScreenKey.ConnectingBle ->
ScanningBleContent(
error = uiState.error as? WifiProvisionError.ConnectFailed,
onRetry = { viewModel.connectToDevice(address) },
)
ScreenKey.DeviceFound ->
DeviceFoundContent(
@@ -272,11 +279,29 @@ private val Phase.isLoading: Boolean
/** BLE scanning spinner — shown while searching for a device. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun ScanningBleContent() {
internal fun ScanningBleContent(error: WifiProvisionError.ConnectFailed? = null, onRetry: () -> Unit = {}) {
CenteredStatusContent {
LoadingIndicator(modifier = Modifier.size(48.dp))
Spacer(Modifier.height(24.dp))
Text(stringResource(Res.string.wifi_provision_scanning_ble), style = MaterialTheme.typography.bodyLarge)
if (error != null) {
Icon(
MeshtasticIcons.Bluetooth,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(24.dp))
Text(
stringResource(Res.string.wifi_provision_connect_failed, error.detail),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
FilledTonalButton(onClick = onRetry) { Text(stringResource(Res.string.retry)) }
} else {
LoadingIndicator(modifier = Modifier.size(48.dp))
Spacer(Modifier.height(24.dp))
Text(stringResource(Res.string.wifi_provision_scanning_ble), style = MaterialTheme.typography.bodyLarge)
}
}
}
@@ -349,7 +374,7 @@ internal fun ConnectedContent(
isProvisioning: Boolean,
isScanning: Boolean,
onScanNetworks: () -> Unit,
onProvision: (ssid: String, password: String) -> Unit,
onProvision: (ssid: String, password: String, hidden: Boolean) -> Unit,
onDisconnect: () -> Unit,
) {
if (provisionStatus == ProvisionStatus.Success) {
@@ -360,6 +385,7 @@ internal fun ConnectedContent(
var ssid by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
var hiddenNetwork by rememberSaveable { mutableStateOf(false) }
val haptic = LocalHapticFeedback.current
LaunchedEffect(provisionStatus) {
@@ -472,10 +498,20 @@ internal fun ConnectedContent(
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { onProvision(ssid, password) }),
keyboardActions = KeyboardActions(onDone = { onProvision(ssid, password, hiddenNetwork) }),
modifier = Modifier.fillMaxWidth(),
)
// Hidden network toggle
Row(
modifier = Modifier.fillMaxWidth().clickable(role = Role.Switch) { hiddenNetwork = !hiddenNetwork },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(Res.string.wifi_provision_hidden_network), style = MaterialTheme.typography.bodyMedium)
Switch(checked = hiddenNetwork, onCheckedChange = { hiddenNetwork = it })
}
// Inline provision status (matches web flasher's status chip) — animated entrance
AnimatedVisibility(
visible = provisionStatus != ProvisionStatus.Idle || isProvisioning,
@@ -489,7 +525,7 @@ internal fun ConnectedContent(
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(onClick = onDisconnect) { Text(stringResource(Res.string.cancel)) }
Button(
onClick = { onProvision(ssid, password) },
onClick = { onProvision(ssid, password, hiddenNetwork) },
enabled = ssid.isNotBlank() && !isProvisioning,
modifier = Modifier.weight(1f),
) {