mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-02 06:24:16 +02:00
Brownfield gap remediation: 28 tasks + intro commonMain migration (#5401)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+7
@@ -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,
|
||||
}
|
||||
|
||||
+310
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -37,5 +37,6 @@ kotlin {
|
||||
|
||||
implementation(libs.jetbrains.navigation3.ui)
|
||||
}
|
||||
androidMain.dependencies { implementation(projects.core.service) }
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -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)
|
||||
}
|
||||
}
|
||||
+23
-12
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+18
-12
@@ -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 = {}) } }
|
||||
}
|
||||
+9
@@ -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 = {}) } }
|
||||
}
|
||||
+23
-35
@@ -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") }
|
||||
+31
@@ -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") }
|
||||
+1
-2
@@ -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,
|
||||
+20
-14
@@ -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 = {}) } }
|
||||
}
|
||||
+20
-12
@@ -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 = {}) }
|
||||
}
|
||||
}
|
||||
+8
-6
@@ -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
|
||||
}
|
||||
+80
-1
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+9
-5
@@ -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) {
|
||||
|
||||
+5
-1
@@ -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)) {
|
||||
|
||||
+11
-5
@@ -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
|
||||
|
||||
+55
-24
@@ -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
|
||||
|
||||
+7
-1
@@ -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) }
|
||||
|
||||
+143
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+77
-1
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -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
|
||||
|
||||
+11
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -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())
|
||||
}
|
||||
}
|
||||
+203
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+90
@@ -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)
|
||||
|
||||
+254
@@ -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")
|
||||
}
|
||||
}
|
||||
+65
@@ -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"))
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+44
-8
@@ -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),
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user