mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-02 16:44:33 -04:00
fix: keep WiFi credentials, addresses and coordinates out of app logs (#7419)
This commit is contained in:
1 parent
c7d7bc766d
commit
b63e566280
27 files changed
+871
-236
No files matched your search
+7
-22
@@ -16,10 +16,7 @@
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.LogWriter
|
||||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
import co.touchlab.kermit.platformLogWriter
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.calls
|
||||
import dev.mokkery.answering.returns
|
||||
@@ -67,6 +64,7 @@ import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.SessionManager
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.core.testing.CapturingLogWriter
|
||||
import org.meshtastic.core.testing.FakeLockdownCoordinator
|
||||
import org.meshtastic.core.testing.FakeNodeRepository
|
||||
import org.meshtastic.proto.Config
|
||||
@@ -195,22 +193,9 @@ class MeshConnectionManagerImplTest {
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
Logger.setLogWriters(platformLogWriter())
|
||||
CapturingLogWriter.uninstall()
|
||||
}
|
||||
|
||||
private class CapturingLogWriter : LogWriter() {
|
||||
val entries = mutableListOf<Pair<Severity, String>>()
|
||||
|
||||
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
|
||||
entries += severity to message
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureLogs(): CapturingLogWriter = CapturingLogWriter().also { Logger.setLogWriters(it) }
|
||||
|
||||
private fun CapturingLogWriter.messages(severity: Severity): List<String> =
|
||||
entries.filter { it.first == severity }.map { it.second }
|
||||
|
||||
@Test
|
||||
fun `Connected state triggers broadcast and config start`() = runTest(testDispatcher) {
|
||||
manager = createManager(backgroundScope)
|
||||
@@ -991,7 +976,7 @@ class MeshConnectionManagerImplTest {
|
||||
@Test
|
||||
fun `TCP Stage 1 stall report names TCP`() = runTest(testDispatcher) {
|
||||
every { radioInterfaceService.getDeviceAddress() } returns "t192.168.1.42"
|
||||
val logs = captureLogs()
|
||||
val logs = CapturingLogWriter.install()
|
||||
manager = createManager(backgroundScope)
|
||||
radioConnectionState.value = ConnectionState.Connected
|
||||
advanceTimeBy(200)
|
||||
@@ -1012,7 +997,7 @@ class MeshConnectionManagerImplTest {
|
||||
@Test
|
||||
fun `USB Stage 1 stall report names USB`() = runTest(testDispatcher) {
|
||||
every { radioInterfaceService.getDeviceAddress() } returns "s/dev/bus/usb/001/002"
|
||||
val logs = captureLogs()
|
||||
val logs = CapturingLogWriter.install()
|
||||
manager = createManager(backgroundScope)
|
||||
radioConnectionState.value = ConnectionState.Connected
|
||||
advanceTimeBy(200)
|
||||
@@ -1034,7 +1019,7 @@ class MeshConnectionManagerImplTest {
|
||||
@Test
|
||||
fun `Demo Mode keeps the fast stall budget and names MOCK`() = runTest(testDispatcher) {
|
||||
every { radioInterfaceService.getDeviceAddress() } returns "m"
|
||||
val logs = captureLogs()
|
||||
val logs = CapturingLogWriter.install()
|
||||
manager = createManager(backgroundScope)
|
||||
radioConnectionState.value = ConnectionState.Connected
|
||||
advanceTimeBy(200)
|
||||
@@ -1056,7 +1041,7 @@ class MeshConnectionManagerImplTest {
|
||||
@Test
|
||||
fun `BLE Stage 1 stall report names BLE`() = runTest(testDispatcher) {
|
||||
every { radioInterfaceService.getDeviceAddress() } returns "xAA:BB:CC:DD:EE:FF"
|
||||
val logs = captureLogs()
|
||||
val logs = CapturingLogWriter.install()
|
||||
manager = createManager(backgroundScope)
|
||||
radioConnectionState.value = ConnectionState.Connected
|
||||
advanceTimeBy(200)
|
||||
@@ -1075,7 +1060,7 @@ class MeshConnectionManagerImplTest {
|
||||
@Test
|
||||
fun `TCP fast watchdog report names TCP`() = runTest(testDispatcher) {
|
||||
every { radioInterfaceService.getDeviceAddress() } returns "t192.168.1.42"
|
||||
val logs = captureLogs()
|
||||
val logs = CapturingLogWriter.install()
|
||||
manager = createManager(backgroundScope)
|
||||
radioConnectionState.value = ConnectionState.Connected
|
||||
advanceTimeBy(200)
|
||||
|
||||
+5
-9
@@ -21,9 +21,8 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.safeCatching
|
||||
import org.meshtastic.core.datastore.di.CorePreferencesDataStore
|
||||
|
||||
@Single
|
||||
@@ -37,13 +36,10 @@ open class BootloaderWarningDataSource(private val dataStore: CorePreferencesDat
|
||||
dataStore.data.map { preferences ->
|
||||
val jsonString = preferences[PreferencesKeys.DISMISSED_BOOTLOADER_ADDRESSES] ?: return@map emptySet()
|
||||
|
||||
runCatching { Json.decodeFromString<List<String>>(jsonString).toSet() }
|
||||
// The stored value is a list of device addresses, so the log names only the exception type.
|
||||
safeCatching { DatastoreJson.decodeFromString<List<String>>(jsonString).toSet() }
|
||||
.onFailure { e ->
|
||||
if (e is IllegalArgumentException || e is SerializationException) {
|
||||
Logger.w(e) { "Failed to parse dismissed bootloader warning addresses, resetting preference" }
|
||||
} else {
|
||||
Logger.w(e) { "Unexpected error while parsing dismissed bootloader warning addresses" }
|
||||
}
|
||||
Logger.w { "Ignoring unreadable dismissed bootloader warning addresses (${e::class.simpleName})" }
|
||||
}
|
||||
.getOrDefault(emptySet())
|
||||
}
|
||||
@@ -58,7 +54,7 @@ open class BootloaderWarningDataSource(private val dataStore: CorePreferencesDat
|
||||
|
||||
val updated = (current + address).toList()
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.DISMISSED_BOOTLOADER_ADDRESSES] = Json.encodeToString(updated)
|
||||
preferences[PreferencesKeys.DISMISSED_BOOTLOADER_ADDRESSES] = DatastoreJson.encodeToString(updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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.core.datastore
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Json for the JSON strings this module stores, which hold user data such as device addresses. Exceptions omit the JSON
|
||||
* input, though a message can still quote one token, so callers log only the exception type. Not injected from Koin:
|
||||
* Json is sealed, so Mokkery could no longer mock a data source that takes one.
|
||||
*/
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
internal val DatastoreJson = Json { exceptionsWithDebugInfo = false }
|
||||
+7
-9
@@ -21,9 +21,8 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.safeCatching
|
||||
import org.meshtastic.core.datastore.di.CorePreferencesDataStore
|
||||
import org.meshtastic.core.datastore.model.PendingFirmwareRecovery
|
||||
|
||||
@@ -42,20 +41,19 @@ open class FirmwareRecoveryDataSource(private val dataStore: CorePreferencesData
|
||||
open val pending: Flow<PendingFirmwareRecovery?> =
|
||||
dataStore.data.map { preferences ->
|
||||
val jsonString = preferences[PreferencesKeys.PENDING_RECOVERY] ?: return@map null
|
||||
runCatching { Json.decodeFromString<PendingFirmwareRecovery>(jsonString) }
|
||||
// The stored record holds the device address and name, so the log names only the exception type.
|
||||
safeCatching { DatastoreJson.decodeFromString<PendingFirmwareRecovery>(jsonString) }
|
||||
.onFailure { e ->
|
||||
if (e is IllegalArgumentException || e is SerializationException) {
|
||||
Logger.w(e) { "Failed to parse pending firmware recovery, clearing preference" }
|
||||
} else {
|
||||
Logger.w(e) { "Unexpected error parsing pending firmware recovery" }
|
||||
}
|
||||
Logger.w { "Ignoring unreadable pending firmware recovery (${e::class.simpleName})" }
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
/** Records [recovery] as the outstanding interrupted update, replacing any previous record. */
|
||||
open suspend fun set(recovery: PendingFirmwareRecovery) {
|
||||
dataStore.edit { preferences -> preferences[PreferencesKeys.PENDING_RECOVERY] = Json.encodeToString(recovery) }
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.PENDING_RECOVERY] = DatastoreJson.encodeToString(recovery)
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the outstanding recovery record (update finished, or the device returned on its own). */
|
||||
|
||||
+18
-19
@@ -22,18 +22,19 @@ import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.datastore.di.CorePreferencesDataStore
|
||||
import org.meshtastic.core.datastore.model.RecentAddress
|
||||
|
||||
/**
|
||||
* The stored addresses and names are user data, so no log line here carries the stored value or an exception message,
|
||||
* which kotlinx.serialization can fill with the input it failed on.
|
||||
*/
|
||||
@Single
|
||||
open class RecentAddressesDataSource(private val dataStore: CorePreferencesDataStore) {
|
||||
private object PreferencesKeys {
|
||||
@@ -45,14 +46,10 @@ open class RecentAddressesDataSource(private val dataStore: CorePreferencesDataS
|
||||
val jsonString = preferences[PreferencesKeys.RECENT_IP_ADDRESSES]
|
||||
if (jsonString != null) {
|
||||
try {
|
||||
Json.decodeFromString<List<RecentAddress>>(jsonString)
|
||||
DatastoreJson.decodeFromString<List<RecentAddress>>(jsonString)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Logger.w { "Could not parse recent addresses, falling back to legacy parsing: ${e.message}" }
|
||||
// Fallback to legacy parsing
|
||||
parseLegacyRecentAddresses(jsonString)
|
||||
} catch (e: SerializationException) {
|
||||
Logger.w { "Could not parse recent addresses, falling back to legacy parsing: ${e.message}" }
|
||||
// Fallback to legacy parsing
|
||||
// SerializationException is an IllegalArgumentException.
|
||||
Logger.w { "Could not parse recent addresses (${e::class.simpleName}), trying legacy format" }
|
||||
parseLegacyRecentAddresses(jsonString)
|
||||
}
|
||||
} else {
|
||||
@@ -60,19 +57,21 @@ open class RecentAddressesDataSource(private val dataStore: CorePreferencesDataS
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseLegacyRecentAddresses(jsonAddresses: String): List<RecentAddress> {
|
||||
val jsonArray = Json.parseToJsonElement(jsonAddresses).jsonArray
|
||||
return jsonArray.mapNotNull(::parseLegacyRecentAddress)
|
||||
private fun parseLegacyRecentAddresses(jsonAddresses: String): List<RecentAddress> = try {
|
||||
DatastoreJson.parseToJsonElement(jsonAddresses).jsonArray.mapNotNull(::parseLegacyRecentAddress)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Logger.w { "Discarding unreadable recent addresses (${e::class.simpleName})" }
|
||||
emptyList()
|
||||
}
|
||||
|
||||
private fun parseLegacyRecentAddress(item: kotlinx.serialization.json.JsonElement): RecentAddress? = when (item) {
|
||||
is JsonObject -> {
|
||||
val address = item["address"]?.jsonPrimitive?.contentOrNull
|
||||
val name = item["name"]?.jsonPrimitive?.contentOrNull
|
||||
val address = (item["address"] as? JsonPrimitive)?.contentOrNull
|
||||
val name = (item["name"] as? JsonPrimitive)?.contentOrNull
|
||||
if (address != null && name != null) {
|
||||
RecentAddress(address = address, name = name)
|
||||
} else {
|
||||
Logger.w { "Skipping malformed recent address object: $item" }
|
||||
Logger.w { "Skipping malformed recent address object" }
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -82,20 +81,20 @@ open class RecentAddressesDataSource(private val dataStore: CorePreferencesDataS
|
||||
if (address != null) {
|
||||
RecentAddress(address = address, name = "Meshtastic")
|
||||
} else {
|
||||
Logger.w { "Skipping malformed recent address primitive: $item" }
|
||||
Logger.w { "Skipping malformed recent address primitive" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
is JsonArray -> {
|
||||
Logger.w { "Skipping nested array in recent IP addresses: $item" }
|
||||
Logger.w { "Skipping nested array in recent IP addresses" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
open suspend fun setRecentAddresses(addresses: List<RecentAddress>) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.RECENT_IP_ADDRESSES] = Json.encodeToString(addresses)
|
||||
preferences[PreferencesKeys.RECENT_IP_ADDRESSES] = DatastoreJson.encodeToString(addresses)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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.core.datastore
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
import org.meshtastic.core.datastore.di.asCorePreferencesDataStore
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class BootloaderWarningDataSourceTest {
|
||||
private lateinit var tmpDir: Path
|
||||
private lateinit var dataStore: DataStore<Preferences>
|
||||
private lateinit var dataSource: BootloaderWarningDataSource
|
||||
private lateinit var logs: CapturingLogWriter
|
||||
|
||||
private val testScope = TestScope(UnconfinedTestDispatcher())
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "bootloaderWarningTest-${Uuid.random()}"
|
||||
FileSystem.SYSTEM.createDirectories(tmpDir)
|
||||
dataStore =
|
||||
PreferenceDataStoreFactory.createWithPath(
|
||||
scope = testScope,
|
||||
produceFile = { tmpDir / "test.preferences_pb" },
|
||||
)
|
||||
dataSource = BootloaderWarningDataSource(dataStore.asCorePreferencesDataStore())
|
||||
logs = CapturingLogWriter.install()
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
CapturingLogWriter.uninstall()
|
||||
FileSystem.SYSTEM.deleteRecursively(tmpDir)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dismissed address reads back as dismissed`() = testScope.runTest {
|
||||
dataSource.dismiss("AA:BB:CC:00:00:01")
|
||||
|
||||
assertTrue(dataSource.isDismissed("AA:BB:CC:00:00:01"))
|
||||
assertFalse(dataSource.isDismissed("AA:BB:CC:00:00:02"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unreadable stored value counts as not dismissed and is never logged`() = testScope.runTest {
|
||||
dataStore.edit { it[stringPreferencesKey("dismissed-bootloader-addresses")] = """["AA:BB:CC:11:22:33",""" }
|
||||
|
||||
assertFalse(dataSource.isDismissed("AA:BB:CC:11:22:33"))
|
||||
logs.assertNotLogged("AA:BB:CC:11:22:33")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismiss replaces an unreadable stored value with a readable one`() = testScope.runTest {
|
||||
dataStore.edit { it[stringPreferencesKey("dismissed-bootloader-addresses")] = """["AA:BB:CC:11:22:33",""" }
|
||||
|
||||
dataSource.dismiss("AA:BB:CC:00:00:01")
|
||||
|
||||
assertTrue(dataSource.isDismissed("AA:BB:CC:00:00:01"))
|
||||
}
|
||||
}
|
||||
+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.core.datastore
|
||||
|
||||
import co.touchlab.kermit.LogWriter
|
||||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
import co.touchlab.kermit.platformLogWriter
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Records every Kermit line, including an attached throwable's full text, so a test can assert what never reached a
|
||||
* log. Install it in setup and call [uninstall] in teardown: it replaces the global writers.
|
||||
*/
|
||||
internal class CapturingLogWriter : LogWriter() {
|
||||
private val entries = mutableListOf<String>()
|
||||
|
||||
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
|
||||
entries += message
|
||||
throwable?.let { entries += it.stackTraceToString() }
|
||||
}
|
||||
|
||||
fun assertNotLogged(vararg values: String) {
|
||||
assertTrue(entries.isNotEmpty(), "Expected the failed parse to log a warning")
|
||||
for (value in values) {
|
||||
assertFalse(entries.any { value in it }, "'$value' leaked into logs: $entries")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun install(): CapturingLogWriter = CapturingLogWriter().also {
|
||||
Logger.setLogWriters(it)
|
||||
Logger.setMinSeverity(Severity.Verbose)
|
||||
}
|
||||
|
||||
fun uninstall() {
|
||||
Logger.setLogWriters(platformLogWriter())
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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.core.datastore
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
import org.meshtastic.core.datastore.di.asCorePreferencesDataStore
|
||||
import org.meshtastic.core.datastore.model.PendingFirmwareRecovery
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class FirmwareRecoveryDataSourceTest {
|
||||
private lateinit var tmpDir: Path
|
||||
private lateinit var dataStore: DataStore<Preferences>
|
||||
private lateinit var dataSource: FirmwareRecoveryDataSource
|
||||
private lateinit var logs: CapturingLogWriter
|
||||
|
||||
private val testScope = TestScope(UnconfinedTestDispatcher())
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "firmwareRecoveryTest-${Uuid.random()}"
|
||||
FileSystem.SYSTEM.createDirectories(tmpDir)
|
||||
dataStore =
|
||||
PreferenceDataStoreFactory.createWithPath(
|
||||
scope = testScope,
|
||||
produceFile = { tmpDir / "test.preferences_pb" },
|
||||
)
|
||||
dataSource = FirmwareRecoveryDataSource(dataStore.asCorePreferencesDataStore())
|
||||
logs = CapturingLogWriter.install()
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
CapturingLogWriter.uninstall()
|
||||
FileSystem.SYSTEM.deleteRecursively(tmpDir)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a recorded recovery reads back until cleared`() = testScope.runTest {
|
||||
val recovery =
|
||||
PendingFirmwareRecovery(
|
||||
fullAddress = "xAA:BB:CC:00:00:01",
|
||||
hwModel = 9,
|
||||
pioEnv = "rak4631",
|
||||
releaseType = "STABLE",
|
||||
deviceName = "Base",
|
||||
)
|
||||
|
||||
dataSource.set(recovery)
|
||||
assertEquals(recovery, dataSource.pending.first())
|
||||
|
||||
dataSource.clear()
|
||||
assertNull(dataSource.pending.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unreadable stored record reads as null without logging it`() = testScope.runTest {
|
||||
val truncated = """{"fullAddress":"xAA:BB:CC:11:22:33","deviceName":"CabinBase","hwModel":"""
|
||||
dataStore.edit { it[stringPreferencesKey("pending-firmware-recovery")] = truncated }
|
||||
|
||||
assertNull(dataSource.pending.first())
|
||||
logs.assertNotLogged("AA:BB:CC:11:22:33", "CabinBase")
|
||||
}
|
||||
}
|
||||
+59
-57
@@ -16,20 +16,15 @@
|
||||
*/
|
||||
package org.meshtastic.core.datastore
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
import org.meshtastic.core.datastore.di.asCorePreferencesDataStore
|
||||
@@ -44,7 +39,9 @@ import kotlin.uuid.Uuid
|
||||
|
||||
class RecentAddressesDataSourceTest {
|
||||
private lateinit var tmpDir: Path
|
||||
private lateinit var dataStore: DataStore<Preferences>
|
||||
private lateinit var dataSource: RecentAddressesDataSource
|
||||
private lateinit var logs: CapturingLogWriter
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
@@ -53,19 +50,25 @@ class RecentAddressesDataSourceTest {
|
||||
fun setup() {
|
||||
tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "recentAddressesTest-${Uuid.random()}"
|
||||
FileSystem.SYSTEM.createDirectories(tmpDir)
|
||||
val dataStore =
|
||||
dataStore =
|
||||
PreferenceDataStoreFactory.createWithPath(
|
||||
scope = testScope,
|
||||
produceFile = { tmpDir / "test.preferences_pb" },
|
||||
)
|
||||
dataSource = RecentAddressesDataSource(dataStore.asCorePreferencesDataStore())
|
||||
logs = CapturingLogWriter.install()
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
CapturingLogWriter.uninstall()
|
||||
FileSystem.SYSTEM.deleteRecursively(tmpDir)
|
||||
}
|
||||
|
||||
private suspend fun storeRaw(value: String) {
|
||||
dataStore.edit { it[stringPreferencesKey("recent-ip-addresses")] = value }
|
||||
}
|
||||
|
||||
// ---- recentAddresses flow ----
|
||||
|
||||
@Test
|
||||
@@ -97,6 +100,16 @@ class RecentAddressesDataSourceTest {
|
||||
assertEquals("5.6.7.8", result[0].address)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `corrupt stored value yields an empty list without logging the stored addresses`() = testScope.runTest {
|
||||
storeRaw("""[{"address":"10.20.30.40","name":"CabinRadio",""")
|
||||
|
||||
val result = dataSource.recentAddresses.first()
|
||||
|
||||
assertTrue(result.isEmpty())
|
||||
logs.assertNotLogged("10.20.30.40", "CabinRadio")
|
||||
}
|
||||
|
||||
// ---- add() LRU behaviour ----
|
||||
|
||||
@Test
|
||||
@@ -186,13 +199,12 @@ class RecentAddressesDataSourceTest {
|
||||
assertTrue(dataSource.recentAddresses.first().isEmpty())
|
||||
}
|
||||
|
||||
// ---- legacy JSON parsing (via LegacyParsingHarness) ----
|
||||
// ---- legacy stored formats ----
|
||||
|
||||
@Test
|
||||
fun `legacy JsonObject array is parsed correctly`() = testScope.runTest {
|
||||
val legacyJson =
|
||||
"""[{"address":"192.168.1.100","name":"NodeA"},{"address":"192.168.1.101","name":"NodeB"}]"""
|
||||
val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
|
||||
storeRaw("""[{"address":"192.168.1.100","name":"NodeA"},{"address":"192.168.1.101","name":"NodeB"}]""")
|
||||
val result = dataSource.recentAddresses.first()
|
||||
|
||||
assertEquals(2, result.size)
|
||||
assertEquals("192.168.1.100", result[0].address)
|
||||
@@ -204,8 +216,8 @@ class RecentAddressesDataSourceTest {
|
||||
@Test
|
||||
fun `legacy bare string JsonPrimitive array is parsed correctly`() = testScope.runTest {
|
||||
// Old clients stored plain IP strings with no name field
|
||||
val legacyJson = """["192.168.1.50","10.0.0.2"]"""
|
||||
val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
|
||||
storeRaw("""["192.168.1.50","10.0.0.2"]""")
|
||||
val result = dataSource.recentAddresses.first()
|
||||
|
||||
assertEquals(2, result.size)
|
||||
assertEquals("192.168.1.50", result[0].address)
|
||||
@@ -214,10 +226,19 @@ class RecentAddressesDataSourceTest {
|
||||
assertEquals("Meshtastic", result[1].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy value is parsed without logging the stored addresses`() = testScope.runTest {
|
||||
storeRaw("""["192.168.1.50","10.0.0.2"]""")
|
||||
|
||||
dataSource.recentAddresses.first()
|
||||
|
||||
logs.assertNotLogged("192.168.1.50", "10.0.0.2")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy JsonObject missing address field is skipped`() = testScope.runTest {
|
||||
val legacyJson = """[{"name":"NoAddress"},{"address":"1.2.3.4","name":"Good"}]"""
|
||||
val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
|
||||
storeRaw("""[{"name":"NoAddress"},{"address":"1.2.3.4","name":"Good"}]""")
|
||||
val result = dataSource.recentAddresses.first()
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("1.2.3.4", result[0].address)
|
||||
@@ -225,63 +246,44 @@ class RecentAddressesDataSourceTest {
|
||||
|
||||
@Test
|
||||
fun `legacy JsonObject missing name field is skipped`() = testScope.runTest {
|
||||
val legacyJson = """[{"address":"1.2.3.4"},{"address":"5.6.7.8","name":"Good"}]"""
|
||||
val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
|
||||
storeRaw("""[{"address":"1.2.3.4"},{"address":"5.6.7.8","name":"Good"}]""")
|
||||
val result = dataSource.recentAddresses.first()
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("5.6.7.8", result[0].address)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy JsonObject with non-primitive fields is skipped and keeps the other entries`() = testScope.runTest {
|
||||
storeRaw(
|
||||
"""[{"address":{},"name":"BadA"},{"address":"9.9.9.9","name":["BadB"]},""" +
|
||||
"""{"address":"1.2.3.4","name":"Good"}]""",
|
||||
)
|
||||
val result = dataSource.recentAddresses.first()
|
||||
|
||||
assertEquals(listOf(RecentAddress("1.2.3.4", "Good")), result)
|
||||
logs.assertNotLogged("BadA", "9.9.9.9", "BadB", "1.2.3.4", "Good")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy nested JsonArray entries are skipped`() = testScope.runTest {
|
||||
val legacyJson = """[["nested","array"],{"address":"1.2.3.4","name":"Good"}]"""
|
||||
val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
|
||||
storeRaw("""[["nested","array"],{"address":"1.2.3.4","name":"Good"}]""")
|
||||
val result = dataSource.recentAddresses.first()
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("1.2.3.4", result[0].address)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy mixed array handles all element types`() = testScope.runTest {
|
||||
fun `legacy mixed array handles all element types without logging entries`() = testScope.runTest {
|
||||
// JsonPrimitive + valid JsonObject + malformed JsonObject + nested JsonArray
|
||||
val legacyJson = """["10.0.0.1",{"address":"10.0.0.2","name":"Node"},{"name":"bad"},[1,2]]"""
|
||||
val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
|
||||
storeRaw("""["10.0.0.1",{"address":"10.0.0.2","name":"Node"},{"name":"BadEntryName"},["10.9.9.9"]]""")
|
||||
val result = dataSource.recentAddresses.first()
|
||||
|
||||
assertEquals(2, result.size)
|
||||
assertEquals("10.0.0.1", result[0].address)
|
||||
assertEquals("Meshtastic", result[0].name)
|
||||
assertEquals("10.0.0.2", result[1].address)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test harness that mirrors the private legacy parsing logic of [RecentAddressesDataSource] without needing to bypass
|
||||
* encapsulation. Exposes a [Flow] that emits the result of parsing a raw legacy JSON string using the same rules as the
|
||||
* production fallback path.
|
||||
*/
|
||||
private class LegacyParsingHarness(private val rawJson: String) {
|
||||
val recentAddresses: Flow<List<RecentAddress>> = flow {
|
||||
val jsonArray = Json.parseToJsonElement(rawJson).jsonArray
|
||||
emit(
|
||||
jsonArray.mapNotNull { item ->
|
||||
when (item) {
|
||||
is JsonObject -> {
|
||||
val address = item["address"]?.jsonPrimitive?.contentOrNull
|
||||
val name = item["name"]?.jsonPrimitive?.contentOrNull
|
||||
if (address != null && name != null) {
|
||||
RecentAddress(address = address, name = name)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
is JsonPrimitive -> {
|
||||
item.contentOrNull?.let { RecentAddress(address = it, name = "Meshtastic") }
|
||||
}
|
||||
|
||||
is JsonArray -> null
|
||||
}
|
||||
},
|
||||
)
|
||||
logs.assertNotLogged("10.0.0.1", "10.0.0.2", "BadEntryName", "10.9.9.9")
|
||||
}
|
||||
}
|
||||
+26
-6
@@ -29,12 +29,23 @@ import kotlin.test.assertTrue
|
||||
* attempt anonymised the hand-written log statements in `core/ble` and missed the Kable `identifier`, which stamps the
|
||||
* address onto *every* line the BLE library emits, plus further sites in the DFU transports and WiFi provisioning.
|
||||
*
|
||||
* Scoped to the BLE-adjacent modules so matching on the `address` suffix stays low-noise.
|
||||
* Scoped to the BLE-adjacent modules so matching on the `address` suffix stays low-noise. That scope includes the
|
||||
* transport modules, so TCP hosts go through `anonymizePublicHost()`, which keeps a host on the user's own network
|
||||
* readable.
|
||||
*/
|
||||
class BleAddressLoggingTest {
|
||||
|
||||
private val scannedPathFragments =
|
||||
listOf("/core/ble/", "/feature/firmware/", "/feature/wifi-provision/", "/feature/connections/")
|
||||
listOf(
|
||||
"/core/ble/",
|
||||
"/core/network/",
|
||||
"/core/service/",
|
||||
"/feature/firmware/",
|
||||
"/feature/wifi-provision/",
|
||||
"/feature/connections/",
|
||||
"/androidApp/",
|
||||
"/desktopApp/",
|
||||
)
|
||||
|
||||
/**
|
||||
* Files where an address is used as an identity rather than as diagnostic text — building the connection string or
|
||||
@@ -42,6 +53,12 @@ class BleAddressLoggingTest {
|
||||
*/
|
||||
private val identityUseAllowlist = listOf("DeviceListEntry.kt")
|
||||
|
||||
/**
|
||||
* Files whose `address` names hardware, not a person. The Android serial transport's address is the USB
|
||||
* vendor-product pair (`usbSerialStableKey()`), which identifies the chip model.
|
||||
*/
|
||||
private val notPersonalAddressFiles = listOf("SerialRadioTransport.kt")
|
||||
|
||||
/** Interpolation of anything ending in `address`, e.g. `${device.address}` or `$address`. */
|
||||
private val interpolatedAddress = Regex("""\$\{?[A-Za-z0-9_.]*[aA]ddress}?""")
|
||||
|
||||
@@ -56,16 +73,19 @@ class BleAddressLoggingTest {
|
||||
.filterNot { it.isNestedAgentWorktree() }
|
||||
.filter { file -> scannedPathFragments.any { it in file.scanPath } }
|
||||
.filterNot { file -> identityUseAllowlist.any { file.scanPath.endsWith(it) } }
|
||||
.filterNot { file -> notPersonalAddressFiles.any { file.scanPath.endsWith(it) } }
|
||||
|
||||
@Test
|
||||
fun `the scan actually reaches the BLE sources`() {
|
||||
val paths = scannedFiles().map { it.scanPath }
|
||||
|
||||
assertTrue(paths.isNotEmpty(), emptyScanMessage("BLE-scoped scan"))
|
||||
assertTrue(
|
||||
paths.any { it.endsWith("KableBleConnection.kt") },
|
||||
"expected core/ble sources in scope; got ${paths.size} files, e.g. ${paths.take(3)}",
|
||||
)
|
||||
for (file in listOf("KableBleConnection.kt", "BleRadioTransport.kt", "SharedRadioInterfaceService.kt")) {
|
||||
assertTrue(
|
||||
paths.any { it.endsWith(file) },
|
||||
"expected $file in scope; got ${paths.size} files, e.g. ${paths.take(3)}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.core.model.util
|
||||
|
||||
/**
|
||||
* [anonymize] for a TCP `host`, `host:port` or `[ipv6]:port`, except that a host which can only name a machine on the
|
||||
* user's own network is returned whole: loopback, RFC 1918, IPv4 and IPv6 link-local, IPv6 unique local, or an mDNS
|
||||
* `.local` name. A public address or DNS name, DDNS and MagicDNS included, can identify the user, so it is anonymized.
|
||||
*/
|
||||
fun String.anonymizePublicHost(): String = if (isLocalNetworkHost(hostOf(this))) this else anonymize()
|
||||
|
||||
private fun hostOf(address: String): String {
|
||||
val host =
|
||||
when {
|
||||
address.startsWith("[") -> address.substringAfter('[').substringBefore(']')
|
||||
address.count { it == ':' } == 1 -> address.substringBefore(':')
|
||||
else -> address
|
||||
}
|
||||
return host.substringBefore('%').trimEnd('.').lowercase()
|
||||
}
|
||||
|
||||
private fun isLocalNetworkHost(host: String): Boolean =
|
||||
host == "localhost" || host.endsWith(".local") || isLocalIpv4(host) || isLocalIpv6(host)
|
||||
|
||||
private fun isLocalIpv4(host: String): Boolean {
|
||||
val parts = host.split('.')
|
||||
val octets = parts.mapNotNull { part -> part.toIntOrNull()?.takeIf { it in 0..255 } }
|
||||
if (parts.size != 4 || octets.size != 4) return false
|
||||
val (first, second) = octets
|
||||
return when (first) {
|
||||
127,
|
||||
10,
|
||||
-> true
|
||||
|
||||
172 -> second in 16..31
|
||||
|
||||
192 -> second == 168
|
||||
|
||||
169 -> second == 254
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLocalIpv6(host: String): Boolean {
|
||||
val isLiteral = host.count { it == ':' } >= 2 && host.all { it == ':' || it.digitToIntOrNull(16) != null }
|
||||
val firstHextet = host.substringBefore(':').takeIf { it.length in 1..4 }?.toIntOrNull(16)
|
||||
// fe80::/10 link-local, fc00::/7 unique local.
|
||||
val isLocalRange = firstHextet != null && (firstHextet in 0xfe80..0xfebf || firstHextet in 0xfc00..0xfdff)
|
||||
return isLiteral && (host == "::1" || isLocalRange)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.core.model.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class HostAnonymizeTest {
|
||||
|
||||
private fun assertKept(vararg hosts: String) {
|
||||
for (host in hosts) assertEquals(host, host.anonymizePublicHost(), "expected $host to stay readable")
|
||||
}
|
||||
|
||||
private fun assertAnonymized(vararg hosts: String) {
|
||||
for (host in hosts) assertEquals(host.anonymize(), host.anonymizePublicHost(), "expected $host anonymized")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loopback hosts stay readable`() {
|
||||
assertKept("127.0.0.1", "127.0.0.1:4403", "localhost", "::1", "[::1]:4403")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RFC 1918 private IPv4 hosts stay readable`() {
|
||||
assertKept("10.0.0.5", "172.16.0.1", "172.31.255.254:4403", "192.168.1.50", "192.168.1.50:4403")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addresses just outside the RFC 1918 blocks are anonymized`() {
|
||||
assertAnonymized("172.15.0.1", "172.32.0.1", "11.0.0.1", "192.169.1.1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv4 and IPv6 link-local hosts stay readable`() {
|
||||
assertKept("169.254.10.20", "fe80::1", "febf::1", "[fe80::1%en0]:4403")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv6 unique local hosts stay readable`() {
|
||||
assertKept("fc00::1", "fd12:3456:789a::1", "[fdab::2]:4403")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv6 addresses outside the local blocks are anonymized`() {
|
||||
assertAnonymized("fec0::1", "2001:db8::1", "[2606:4700::1111]:4403")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mDNS local names stay readable`() {
|
||||
assertKept("meshtastic.local", "Meshtastic.local:4403", "node.local.")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `public IPv4 addresses are anonymized`() {
|
||||
assertAnonymized("8.8.8.8", "8.8.8.8:4403", "203.0.113.7")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DDNS and MagicDNS names are anonymized`() {
|
||||
assertAnonymized("mynode.duckdns.org", "node.tail1234.ts.net:4403", "meshnode")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed IPv4 literals are anonymized`() {
|
||||
assertAnonymized("256.1.1.1", "192.168.1", "10.0.0.1.5")
|
||||
}
|
||||
}
|
||||
+6
-14
@@ -16,10 +16,10 @@
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import co.touchlab.kermit.LogWriter
|
||||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
import co.touchlab.kermit.loggerConfigInit
|
||||
import org.meshtastic.core.testing.CapturingLogWriter
|
||||
import org.meshtastic.proto.Position
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
@@ -27,15 +27,7 @@ import kotlin.test.assertNull
|
||||
|
||||
class WireExtensionsTest {
|
||||
|
||||
private class CapturingWriter : LogWriter() {
|
||||
val severities = mutableListOf<Severity>()
|
||||
|
||||
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
|
||||
severities += severity
|
||||
}
|
||||
}
|
||||
|
||||
private val writer = CapturingWriter()
|
||||
private val writer = CapturingLogWriter()
|
||||
private val logger = Logger(loggerConfigInit(writer), tag = "Test")
|
||||
|
||||
// Garbage bytes that are not a valid encoding of any message: decoding must fail, not merely
|
||||
@@ -47,7 +39,7 @@ class WireExtensionsTest {
|
||||
val result = Position.ADAPTER.decodeOrNull(garbage, logger)
|
||||
|
||||
assertNull(result)
|
||||
assertEquals(listOf(Severity.Warn), writer.severities)
|
||||
assertEquals(listOf(Severity.Warn), writer.entries.map { it.severity })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -55,7 +47,7 @@ class WireExtensionsTest {
|
||||
val result = Position.ADAPTER.decodeOrNull(bytes = garbage, logger = logger)
|
||||
|
||||
assertNull(result)
|
||||
assertEquals(listOf(Severity.Warn), writer.severities)
|
||||
assertEquals(listOf(Severity.Warn), writer.entries.map { it.severity })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -63,7 +55,7 @@ class WireExtensionsTest {
|
||||
val result = Position.ADAPTER.decodeOrNull(garbage)
|
||||
|
||||
assertNull(result)
|
||||
assertEquals(emptyList(), writer.severities)
|
||||
assertEquals(emptyList(), writer.entries.map { it.severity })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -71,6 +63,6 @@ class WireExtensionsTest {
|
||||
val result = Position.ADAPTER.decodeOrNull(bytes = null as ByteArray?, logger = logger)
|
||||
|
||||
assertNull(result)
|
||||
assertEquals(emptyList(), writer.severities)
|
||||
assertEquals(emptyList(), writer.entries.map { it.severity })
|
||||
}
|
||||
}
|
||||
+68
-40
@@ -161,7 +161,7 @@ class BleRadioTransport(
|
||||
private val cleanupScope: CoroutineScope = CoroutineScope(SupervisorJob() + scope.coroutineContext.minusKey(Job))
|
||||
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
|
||||
Logger.w(throwable) { "[${address.anonymize()}] Uncaught exception in connectionScope" }
|
||||
if (throwable !is CancellationException) {
|
||||
val session = activeSession.value
|
||||
if (session != null) {
|
||||
@@ -173,7 +173,9 @@ class BleRadioTransport(
|
||||
if (activeSession.value == null) {
|
||||
disconnectGatt("exception handler")
|
||||
} else {
|
||||
Logger.d { "[$address] Skipping exception-handler GATT release; a new session is active" }
|
||||
Logger.d {
|
||||
"[${address.anonymize()}] Skipping exception-handler GATT release; a new session is active"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,7 +273,7 @@ class BleRadioTransport(
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
val failureTime = (nowMillis - connectionStartTime).milliseconds
|
||||
Logger.w(e) { "[$address] Failed to connect after $failureTime" }
|
||||
Logger.w(e) { "[${address.anonymize()}] Failed to connect after $failureTime" }
|
||||
BleReconnectPolicy.Outcome.Failed(e)
|
||||
}
|
||||
},
|
||||
@@ -283,7 +285,9 @@ class BleRadioTransport(
|
||||
// observability surface for this transient event.
|
||||
if (!sessionFailed.value) {
|
||||
error?.let {
|
||||
Logger.w(it) { "[$address] BLE reconnect attempt failed; continuing automatic retry" }
|
||||
Logger.w(it) {
|
||||
"[${address.anonymize()}] BLE reconnect attempt failed; continuing automatic retry"
|
||||
}
|
||||
}
|
||||
callback.onDisconnect(isPermanent = false)
|
||||
}
|
||||
@@ -307,7 +311,7 @@ class BleRadioTransport(
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod", "ReturnCount")
|
||||
private suspend fun attemptConnection(): BleReconnectPolicy.Outcome {
|
||||
connectionStartTime = nowMillis
|
||||
Logger.i { "[$address] BLE connection attempt started" }
|
||||
Logger.i { "[${address.anonymize()}] BLE connection attempt started" }
|
||||
|
||||
awaitPendingSessionCleanup()
|
||||
sessionFailed.value = false
|
||||
@@ -354,7 +358,7 @@ class BleRadioTransport(
|
||||
val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT)
|
||||
|
||||
if (state !is BleConnectionState.Connected) {
|
||||
throw RadioNotConnectedException("Failed to connect to device at address $address")
|
||||
throw RadioNotConnectedException("Failed to connect to device at address ${address.anonymize()}")
|
||||
}
|
||||
|
||||
// GATT cache invalidation has two triggers, both repaired the same way — refresh the platform's cached
|
||||
@@ -389,7 +393,9 @@ class BleRadioTransport(
|
||||
// If a fatal session failure (fromRadio/logRadio error) forced disconnect during setup,
|
||||
// skip the Connected gate — return a retryable failure so BleReconnectPolicy handles it.
|
||||
session.failureCause.value?.let { failure ->
|
||||
Logger.w(failure) { "[$address] Session failed during profile setup — returning failed outcome" }
|
||||
Logger.w(failure) {
|
||||
"[${address.anonymize()}] Session failed during profile setup; returning failed outcome"
|
||||
}
|
||||
return BleReconnectPolicy.Outcome.Failed(failure)
|
||||
}
|
||||
|
||||
@@ -412,7 +418,9 @@ class BleRadioTransport(
|
||||
}
|
||||
if (connectedReached == null) {
|
||||
val failure = session.failureCause.value ?: RuntimeException("Timed out waiting for Connected state gate")
|
||||
Logger.w(failure) { "[$address] Session failed before Connected gate — returning failed outcome" }
|
||||
Logger.w(failure) {
|
||||
"[${address.anonymize()}] Session failed before Connected gate; returning failed outcome"
|
||||
}
|
||||
// Force cleanup only for this exact profile generation. If another path already retired it, await that
|
||||
// generation's cleanup instead of issuing a second disconnect that could race later lifecycle work.
|
||||
isFullyConnected = false
|
||||
@@ -443,14 +451,16 @@ class BleRadioTransport(
|
||||
onDisconnected(session)
|
||||
}
|
||||
|
||||
Logger.i { "[$address] BLE connection dropped (reason: $disconnectReason), preparing to reconnect" }
|
||||
Logger.i {
|
||||
"[${address.anonymize()}] BLE connection dropped (reason: $disconnectReason), preparing to reconnect"
|
||||
}
|
||||
|
||||
// Internal session failures (write/read exceptions that triggered handleFailure →
|
||||
// disconnect) must NOT be treated as intentional/user disconnects — the reconnect policy
|
||||
// needs to escalate backoff for these.
|
||||
val internalFailure = session.failureCause.value
|
||||
if (internalFailure != null) {
|
||||
Logger.w(internalFailure) { "[$address] Session forced disconnect due to internal failure" }
|
||||
Logger.w(internalFailure) { "[${address.anonymize()}] Session forced disconnect due to internal failure" }
|
||||
}
|
||||
val wasIntentional =
|
||||
if (internalFailure != null) {
|
||||
@@ -469,7 +479,7 @@ class BleRadioTransport(
|
||||
|
||||
if (!wasStable && !wasIntentional) {
|
||||
Logger.w {
|
||||
"[$address] Connection lasted only $connectionUptime " +
|
||||
"[${address.anonymize()}] Connection lasted only $connectionUptime " +
|
||||
"(< ${reconnectPolicy.minStableConnection}) — treating as unstable"
|
||||
}
|
||||
}
|
||||
@@ -520,10 +530,10 @@ class BleRadioTransport(
|
||||
|
||||
// Bond before connecting: firmware may require an encrypted link, and without a bond Android fails with
|
||||
// status 5 or 133. Non-Android targets use repository-specific no-op behavior.
|
||||
Logger.i { "[$address] Device not bonded, initiating bonding" }
|
||||
Logger.i { "[${address.anonymize()}] Device not bonded, initiating bonding" }
|
||||
try {
|
||||
bluetoothRepository.bond(device)
|
||||
Logger.i { "[$address] Bonding successful" }
|
||||
Logger.i { "[${address.anonymize()}] Bonding successful" }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
@@ -531,9 +541,12 @@ class BleRadioTransport(
|
||||
// setup. If the device is still not bonded, continuing would fail later with a cryptic status (5/133), so
|
||||
// stop now and let BleReconnectPolicy own the retry/backoff.
|
||||
if (bluetoothRepository.isBonded(address)) {
|
||||
Logger.w(e) { "[$address] Bonding reported failure but device is bonded; continuing" }
|
||||
Logger.w(e) { "[${address.anonymize()}] Bonding reported failure but device is bonded; continuing" }
|
||||
} else {
|
||||
Logger.w(e) { "[$address] Bonding failed and device is still not bonded; stopping connection attempt" }
|
||||
Logger.w(e) {
|
||||
"[${address.anonymize()}] Bonding failed and device is still not bonded; " +
|
||||
"stopping connection attempt"
|
||||
}
|
||||
throw RadioNotConnectedException("Bonding failed and device is still not bonded", e)
|
||||
}
|
||||
}
|
||||
@@ -551,7 +564,7 @@ class BleRadioTransport(
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Failed to read initial connection RSSI" }
|
||||
Logger.w(e) { "[${address.anonymize()}] Failed to read initial connection RSSI" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,7 +573,7 @@ class BleRadioTransport(
|
||||
scheduleSessionCleanup(retired, disconnectGatt = false, phase = "remote disconnect")
|
||||
// Atomic first-writer-wins: if another failure already claimed this session's callback, skip the duplicate.
|
||||
val firstWriter = sessionFailed.compareAndSet(expect = false, update = true)
|
||||
Logger.i { "[$address] BLE disconnected - ${formatSessionStats()}" }
|
||||
Logger.i { "[${address.anonymize()}] BLE disconnected - ${formatSessionStats()}" }
|
||||
if (firstWriter) callback.onDisconnect(isPermanent = false)
|
||||
}
|
||||
|
||||
@@ -578,27 +591,27 @@ class BleRadioTransport(
|
||||
|
||||
radioService.fromRadio
|
||||
.onEach { packet ->
|
||||
Logger.v { "[$address] Received packet fromRadio (${packet.size} bytes)" }
|
||||
Logger.v { "[${address.anonymize()}] Received packet fromRadio (${packet.size} bytes)" }
|
||||
dispatchPacket(packet, session)
|
||||
}
|
||||
.catch { e ->
|
||||
Logger.w(e) { "[$address] Error in fromRadio flow" }
|
||||
Logger.w(e) { "[${address.anonymize()}] Error in fromRadio flow" }
|
||||
handleFailure(e, session)
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
radioService.logRadio
|
||||
.onEach { packet ->
|
||||
Logger.v { "[$address] Received packet logRadio (${packet.size} bytes)" }
|
||||
Logger.v { "[${address.anonymize()}] Received packet logRadio (${packet.size} bytes)" }
|
||||
dispatchPacket(packet, session)
|
||||
}
|
||||
.catch { e ->
|
||||
Logger.w(e) { "[$address] Error in logRadio flow" }
|
||||
Logger.w(e) { "[${address.anonymize()}] Error in logRadio flow" }
|
||||
handleFailure(e, session)
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
Logger.i { "[$address] Profile service active and characteristics subscribed" }
|
||||
Logger.i { "[${address.anonymize()}] Profile service active and characteristics subscribed" }
|
||||
|
||||
// Wait for FROMNUM CCCD write before triggering the Meshtastic handshake.
|
||||
// Bounded: if fromRadio fails before subscriptionReady completes, handleFailure
|
||||
@@ -617,14 +630,17 @@ class BleRadioTransport(
|
||||
?: RuntimeException("Timed out waiting for FROMNUM subscription readiness")
|
||||
Logger.w(cause) {
|
||||
val reason = if (!subscriptionReady) "timed out" else "failed"
|
||||
"[$address] Subscription wait $reason — aborting setup"
|
||||
"[${address.anonymize()}] Subscription wait $reason; aborting setup"
|
||||
}
|
||||
throw cause
|
||||
}
|
||||
|
||||
// Log negotiated MTU for diagnostics
|
||||
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
|
||||
Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
|
||||
Logger.i {
|
||||
"[${address.anonymize()}] BLE Radio Session Ready. " +
|
||||
"Max write length (WITHOUT_RESPONSE): $maxLen bytes"
|
||||
}
|
||||
|
||||
requestHighPriorityAndScheduleDowngrade()
|
||||
|
||||
@@ -656,7 +672,9 @@ class BleRadioTransport(
|
||||
false
|
||||
}
|
||||
if (!published) {
|
||||
Logger.w { "[$address] Session failed or transport closed during setup — skipping onConnect" }
|
||||
Logger.w {
|
||||
"[${address.anonymize()}] Session failed or transport closed during setup; skipping onConnect"
|
||||
}
|
||||
}
|
||||
}
|
||||
return checkNotNull(setupSession) { "BLE profile setup completed without publishing a session" }
|
||||
@@ -666,7 +684,7 @@ class BleRadioTransport(
|
||||
withContext(NonCancellable) { cleanupProfileSetupFailure("cancellation cleanup", setupSession) }
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Profile service discovery or operation failed" }
|
||||
Logger.w(e) { "[${address.anonymize()}] Profile service discovery or operation failed" }
|
||||
// Retire any partially-published profile so the next attempt starts clean. Without this, a failure after
|
||||
// profile publication but before callback.onConnect() could leave a stale generation behind.
|
||||
withContext(NonCancellable) { cleanupProfileSetupFailure("profile error cleanup", setupSession) }
|
||||
@@ -677,7 +695,7 @@ class BleRadioTransport(
|
||||
private suspend fun cleanupProfileSetupFailure(phase: String, expectedSession: BleSession?) {
|
||||
val currentSession = activeSession.value
|
||||
if (expectedSession != null && currentSession != null && currentSession !== expectedSession) {
|
||||
Logger.w { "[$address] Ignoring $phase from an unpublished BLE profile generation" }
|
||||
Logger.w { "[${address.anonymize()}] Ignoring $phase from an unpublished BLE profile generation" }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -706,14 +724,14 @@ class BleRadioTransport(
|
||||
*/
|
||||
private suspend fun CoroutineScope.requestHighPriorityAndScheduleDowngrade() {
|
||||
if (bleConnection.requestHighConnectionPriority()) {
|
||||
Logger.d { "[$address] Requested high BLE connection priority" }
|
||||
Logger.d { "[${address.anonymize()}] Requested high BLE connection priority" }
|
||||
// Wait for the connection parameter update before starting heavy traffic.
|
||||
delay(1.seconds)
|
||||
}
|
||||
launch {
|
||||
delay(PRIORITY_DOWNGRADE_DELAY)
|
||||
if (bleConnection.requestBalancedConnectionPriority()) {
|
||||
Logger.d { "[$address] Downgraded to balanced BLE connection priority" }
|
||||
Logger.d { "[${address.anonymize()}] Downgraded to balanced BLE connection priority" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -793,18 +811,21 @@ class BleRadioTransport(
|
||||
}
|
||||
val sent = packetsSent.incrementAndGet()
|
||||
val txBytes = bytesSent.addAndGet(packet.size.toLong())
|
||||
Logger.v { "[$address] Wrote packet #$sent to toRadio (${packet.size} bytes, total TX: $txBytes bytes)" }
|
||||
Logger.v {
|
||||
"[${address.anonymize()}] Wrote packet #$sent to toRadio " +
|
||||
"(${packet.size} bytes, total TX: $txBytes bytes)"
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (activeSession.value === session) {
|
||||
Logger.w(e) {
|
||||
"[$address] Failed to write packet to toRadioCharacteristic after " +
|
||||
"[${address.anonymize()}] Failed to write packet to toRadioCharacteristic after " +
|
||||
"${packetsSent.value} successful writes"
|
||||
}
|
||||
handleFailure(e, session)
|
||||
} else {
|
||||
Logger.d(e) { "[$address] Stale write failure ignored because the session was replaced" }
|
||||
Logger.d(e) { "[${address.anonymize()}] Stale write failure ignored because the session was replaced" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -825,16 +846,20 @@ class BleRadioTransport(
|
||||
// Closing the outer gate rejects new sends while allowing writes admitted before close to finish.
|
||||
// Once those leases drain, cancel reconnect/heartbeat work before retiring the profile and GATT.
|
||||
connectionScope.cancel()
|
||||
Logger.i { "[$address] Disconnecting. ${formatSessionStats()}" }
|
||||
Logger.i { "[${address.anonymize()}] Disconnecting. ${formatSessionStats()}" }
|
||||
val session = retireActiveSession()
|
||||
val sessionClosed = session?.lifecycle?.close() ?: true
|
||||
awaitPendingSessionCleanup()
|
||||
disconnectGatt("close")
|
||||
if (!sessionClosed) {
|
||||
Logger.w { "[$address] BLE profile teardown did not complete within its lifecycle bounds" }
|
||||
Logger.w {
|
||||
"[${address.anonymize()}] BLE profile teardown did not complete within its lifecycle bounds"
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!completed) Logger.w { "[$address] BLE teardown did not complete within its lifecycle bounds" }
|
||||
if (!completed) {
|
||||
Logger.w { "[${address.anonymize()}] BLE teardown did not complete within its lifecycle bounds" }
|
||||
}
|
||||
} finally {
|
||||
if (!completed) {
|
||||
// The cleanup scope is detached, so a timed-out outer gate needs one final bounded GATT release attempt
|
||||
@@ -852,7 +877,8 @@ class BleRadioTransport(
|
||||
val received = packetsReceived.incrementAndGet()
|
||||
val rxBytes = bytesReceived.addAndGet(packet.size.toLong())
|
||||
Logger.v {
|
||||
"[$address] Dispatching packet #$received " + "(${packet.size} bytes, total RX: $rxBytes bytes)"
|
||||
"[${address.anonymize()}] Dispatching packet #$received " +
|
||||
"(${packet.size} bytes, total RX: $rxBytes bytes)"
|
||||
}
|
||||
callback.handleFromRadio(packet)
|
||||
true
|
||||
@@ -870,7 +896,7 @@ class BleRadioTransport(
|
||||
recordSessionFailureCause(throwable, expectedSession)
|
||||
val retired = retireActiveSession(expectedSession)
|
||||
if (retired == null) {
|
||||
Logger.d(throwable) { "[$address] Ignoring failure from a retired BLE profile generation" }
|
||||
Logger.d(throwable) { "[${address.anonymize()}] Ignoring failure from a retired BLE profile generation" }
|
||||
return
|
||||
}
|
||||
val firstFailure = sessionFailed.compareAndSet(expect = false, update = true)
|
||||
@@ -879,7 +905,7 @@ class BleRadioTransport(
|
||||
val (isPermanent, msg) = throwable.toDisconnectReason()
|
||||
callback.onDisconnect(isPermanent, errorMessage = if (isPermanent) msg else null)
|
||||
}
|
||||
Logger.w(throwable) { "[$address] Session failure — forcing cleanup for reconnect" }
|
||||
Logger.w(throwable) { "[${address.anonymize()}] Session failure; forcing cleanup for reconnect" }
|
||||
scheduleSessionCleanup(retired, disconnectGatt = true, phase = "session failure")
|
||||
}
|
||||
|
||||
@@ -901,7 +927,9 @@ class BleRadioTransport(
|
||||
} else {
|
||||
session.lifecycle.close()
|
||||
}
|
||||
if (!completed) Logger.w { "[$address] BLE profile cleanup timed out during $phase" }
|
||||
if (!completed) {
|
||||
Logger.w { "[${address.anonymize()}] BLE profile cleanup timed out during $phase" }
|
||||
}
|
||||
}
|
||||
.also { pendingSessionCleanup = it }
|
||||
}
|
||||
@@ -927,7 +955,7 @@ class BleRadioTransport(
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Failed to disconnect during $phase" }
|
||||
Logger.w(e) { "[${address.anonymize()}] Failed to disconnect during $phase" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-7
@@ -26,6 +26,7 @@ import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.util.anonymizePublicHost
|
||||
import org.meshtastic.core.network.transport.StreamFrameCodec
|
||||
import org.meshtastic.core.network.transport.TcpTransport
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
@@ -88,7 +89,7 @@ internal constructor(
|
||||
dispatchers = dispatchers,
|
||||
scope = scope,
|
||||
listener = listener,
|
||||
logTag = "TcpRadioTransport[$address]",
|
||||
logTag = "TcpRadioTransport[${address.anonymizePublicHost()}]",
|
||||
),
|
||||
)
|
||||
},
|
||||
@@ -125,7 +126,10 @@ internal constructor(
|
||||
override fun start() {
|
||||
lifecycle.runIfOpen {
|
||||
if (transportStopped.value) {
|
||||
Logger.w { "[$address] Ignoring start on a stopped TCP transport; a fresh transport is required" }
|
||||
Logger.w {
|
||||
"[${address.anonymizePublicHost()}] Ignoring start on a stopped TCP transport; " +
|
||||
"a fresh transport is required"
|
||||
}
|
||||
} else {
|
||||
transport.start(address)
|
||||
}
|
||||
@@ -133,7 +137,7 @@ internal constructor(
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
Logger.d { "[$address] Closing TCP transport" }
|
||||
Logger.d { "[${address.anonymizePublicHost()}] Closing TCP transport" }
|
||||
val completed =
|
||||
lifecycle.close(
|
||||
teardown = {
|
||||
@@ -141,14 +145,16 @@ internal constructor(
|
||||
cancelOutstandingOperations()
|
||||
},
|
||||
)
|
||||
if (!completed) Logger.w { "[$address] TCP teardown did not complete within its lifecycle bounds" }
|
||||
if (!completed) {
|
||||
Logger.w { "[${address.anonymizePublicHost()}] TCP teardown did not complete within its lifecycle bounds" }
|
||||
}
|
||||
// Do NOT emit onDisconnect(isPermanent = true) here. The explicit-disconnect signal is the service layer's
|
||||
// responsibility (SharedRadioInterfaceService.stopTransportLocked); emitting it here causes a double-disconnect
|
||||
// and prevents the auto-reconnect loop from owning its transient lifecycle.
|
||||
}
|
||||
|
||||
override fun keepAlive() {
|
||||
Logger.d { "[$address] TCP keepAlive" }
|
||||
Logger.d { "[${address.anonymizePublicHost()}] TCP keepAlive" }
|
||||
launchConnectionOperation("heartbeat") { transport.sendHeartbeat() }
|
||||
}
|
||||
|
||||
@@ -173,7 +179,9 @@ internal constructor(
|
||||
scope.handledLaunch {
|
||||
val completed = withTimeoutOrNull(OPERATION_TIMEOUT) { block() } != null
|
||||
if (!completed) {
|
||||
Logger.w { "[$address] TCP $operation timed out after $OPERATION_TIMEOUT" }
|
||||
Logger.w {
|
||||
"[${address.anonymizePublicHost()}] TCP $operation timed out after $OPERATION_TIMEOUT"
|
||||
}
|
||||
// Cancellation may leave a framed write partially emitted. Stopping this one-shot
|
||||
// transport forces the service reconnect path to create a fresh transport before another
|
||||
// send.
|
||||
@@ -197,7 +205,9 @@ internal constructor(
|
||||
val jobs = synchronized(operationJobsLock) { operationJobs.toList() }
|
||||
jobs.forEach { it.cancel() }
|
||||
val joined = withTimeoutOrNull(OPERATION_TIMEOUT) { jobs.joinAll() } != null
|
||||
if (!joined) Logger.w { "[$address] TCP operation jobs did not stop after transport teardown" }
|
||||
if (!joined) {
|
||||
Logger.w { "[${address.anonymizePublicHost()}] TCP operation jobs did not stop after transport teardown" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopTransport() {
|
||||
|
||||
+24
-17
@@ -40,6 +40,7 @@ import kotlinx.io.IOException
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.util.anonymizePublicHost
|
||||
import org.meshtastic.proto.ToRadio
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
@@ -209,11 +210,11 @@ class TcpTransport(
|
||||
try {
|
||||
connectAndRead(address)
|
||||
} catch (ex: TimeoutCancellationException) {
|
||||
Logger.w(ex) { "$logTag: [$address] TCP connect timed out" }
|
||||
Logger.w(ex) { "$logTag: [${address.anonymizePublicHost()}] TCP connect timed out" }
|
||||
disconnectSocket()
|
||||
false
|
||||
} catch (ex: IOException) {
|
||||
Logger.w(ex) { "$logTag: [$address] TCP connection error" }
|
||||
Logger.w(ex) { "$logTag: [${address.anonymizePublicHost()}] TCP connection error" }
|
||||
disconnectSocket()
|
||||
false
|
||||
} catch (ce: CancellationException) {
|
||||
@@ -225,9 +226,9 @@ class TcpTransport(
|
||||
// (UnresolvedAddressException, which is an IllegalArgumentException and so misses the IOException
|
||||
// branch above). Log it, retry it, but keep it out of error tracking.
|
||||
if (ex.isExpectedConnectionFailure()) {
|
||||
Logger.w(ex) { "$logTag: [$address] Radio unreachable" }
|
||||
Logger.w(ex) { "$logTag: [${address.anonymizePublicHost()}] Radio unreachable" }
|
||||
} else {
|
||||
Logger.e(ex) { "$logTag: [$address] TCP exception" }
|
||||
Logger.e(ex) { "$logTag: [${address.anonymizePublicHost()}] TCP exception" }
|
||||
}
|
||||
disconnectSocket()
|
||||
false
|
||||
@@ -238,18 +239,22 @@ class TcpTransport(
|
||||
// growing so the radio has time to recover between reconnect attempts.
|
||||
val sessionUptime = if (connectionStartTime > 0) nowMillis - connectionStartTime else 0
|
||||
if (shouldResetBackoff(hadData, sessionUptime, SHORT_SESSION_THRESHOLD_MS)) {
|
||||
Logger.d { "$logTag: [$address] Resetting backoff after successful data exchange (${sessionUptime}ms)" }
|
||||
Logger.d {
|
||||
"$logTag: [${address.anonymizePublicHost()}] Resetting backoff after successful data exchange " +
|
||||
"(${sessionUptime}ms)"
|
||||
}
|
||||
retryCount = 1
|
||||
backoff = MIN_BACKOFF_MILLIS
|
||||
} else if (hadData) {
|
||||
val backoffSec = backoff / MILLIS_PER_SECOND
|
||||
Logger.d {
|
||||
"$logTag: [$address] Short session (${sessionUptime}ms) — keeping backoff at ${backoffSec}s"
|
||||
"$logTag: [${address.anonymizePublicHost()}] Short session (${sessionUptime}ms); " +
|
||||
"keeping backoff at ${backoffSec}s"
|
||||
}
|
||||
}
|
||||
|
||||
val delaySec = backoff / MILLIS_PER_SECOND
|
||||
Logger.i { "$logTag: [$address] Reconnect #$retryCount in ${delaySec}s" }
|
||||
Logger.i { "$logTag: [${address.anonymizePublicHost()}] Reconnect #$retryCount in ${delaySec}s" }
|
||||
delay(backoff)
|
||||
retryCount++
|
||||
backoff = minOf(backoff * 2, MAX_BACKOFF_MILLIS)
|
||||
@@ -264,7 +269,7 @@ class TcpTransport(
|
||||
private suspend fun connectAndRead(address: String): Boolean = withContext(dispatchers.io) {
|
||||
val (host, port) = parseHostAndPort(address)
|
||||
|
||||
Logger.i { "$logTag: [$address] Connecting to $host:$port" }
|
||||
Logger.i { "$logTag: [${address.anonymizePublicHost()}] Connecting to ${host.anonymizePublicHost()}:$port" }
|
||||
val attemptStart = nowMillis
|
||||
|
||||
val selector = SelectorManager(dispatchers.io)
|
||||
@@ -284,7 +289,7 @@ class TcpTransport(
|
||||
resetMetrics()
|
||||
codec.reset()
|
||||
|
||||
Logger.i { "$logTag: [$address] Socket connected in ${connectTime}ms" }
|
||||
Logger.i { "$logTag: [${address.anonymizePublicHost()}] Socket connected in ${connectTime}ms" }
|
||||
|
||||
val output = sock.openWriteChannel(autoFlush = false)
|
||||
writeChannel = output
|
||||
@@ -342,12 +347,12 @@ class TcpTransport(
|
||||
timeoutCount++
|
||||
timeoutEvents++
|
||||
if (timeoutCount % TIMEOUT_LOG_INTERVAL == 0) {
|
||||
Logger.d { "$logTag: [$address] Timeout $timeoutCount/$SOCKET_RETRIES" }
|
||||
Logger.d { "$logTag: [${address.anonymizePublicHost()}] Timeout $timeoutCount/$SOCKET_RETRIES" }
|
||||
}
|
||||
}
|
||||
|
||||
read == -1 -> {
|
||||
Logger.i { "$logTag: [$address] EOF after $packetsReceived packets" }
|
||||
Logger.i { "$logTag: [${address.anonymizePublicHost()}] EOF after $packetsReceived packets" }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -360,7 +365,7 @@ class TcpTransport(
|
||||
}
|
||||
}
|
||||
}
|
||||
Logger.w { "$logTag: [$address] Closing after $SOCKET_RETRIES consecutive timeouts" }
|
||||
Logger.w { "$logTag: [${address.anonymizePublicHost()}] Closing after $SOCKET_RETRIES consecutive timeouts" }
|
||||
}
|
||||
|
||||
// Guards against recursive disconnects triggered by listener callbacks.
|
||||
@@ -375,14 +380,14 @@ class TcpTransport(
|
||||
if (s != null) {
|
||||
val uptime = if (connectionStartTime > 0) nowMillis - connectionStartTime else 0
|
||||
Logger.i {
|
||||
"$logTag: [$currentAddress] Disconnecting - Uptime: ${uptime}ms, " +
|
||||
"$logTag: [${currentAddress?.anonymizePublicHost()}] Disconnecting - Uptime: ${uptime}ms, " +
|
||||
"RX: $packetsReceived ($bytesReceived bytes), " +
|
||||
"TX: $packetsSent ($bytesSent bytes)"
|
||||
}
|
||||
try {
|
||||
s.close()
|
||||
} catch (ex: IOException) {
|
||||
Logger.w(ex) { "$logTag: [$currentAddress] Error closing socket" }
|
||||
Logger.w(ex) { "$logTag: [${currentAddress?.anonymizePublicHost()}] Error closing socket" }
|
||||
}
|
||||
}
|
||||
selectorManager?.close()
|
||||
@@ -408,13 +413,15 @@ class TcpTransport(
|
||||
val stream =
|
||||
writeChannel
|
||||
?: run {
|
||||
Logger.w { "$logTag: [$currentAddress] Cannot send ${p.size} bytes: not connected" }
|
||||
Logger.w {
|
||||
"$logTag: [${currentAddress?.anonymizePublicHost()}] Cannot send ${p.size} bytes: not connected"
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
stream.writeFully(p)
|
||||
} catch (ex: IOException) {
|
||||
Logger.w(ex) { "$logTag: [$currentAddress] TCP write error" }
|
||||
Logger.w(ex) { "$logTag: [${currentAddress?.anonymizePublicHost()}] TCP write error" }
|
||||
disconnectSocket()
|
||||
}
|
||||
}
|
||||
@@ -424,7 +431,7 @@ class TcpTransport(
|
||||
try {
|
||||
stream.flush()
|
||||
} catch (ex: IOException) {
|
||||
Logger.w(ex) { "$logTag: [$currentAddress] TCP flush error" }
|
||||
Logger.w(ex) { "$logTag: [${currentAddress?.anonymizePublicHost()}] TCP flush error" }
|
||||
disconnectSocket()
|
||||
}
|
||||
}
|
||||
|
||||
+10
-18
@@ -16,26 +16,18 @@
|
||||
*/
|
||||
package org.meshtastic.core.network.repository
|
||||
|
||||
import co.touchlab.kermit.LogWriter
|
||||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
import co.touchlab.kermit.loggerConfigInit
|
||||
import kotlinx.io.IOException
|
||||
import org.meshtastic.core.testing.CapturingLogWriter
|
||||
import org.meshtastic.mqtt.MqttLogLevel
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class KermitMqttLoggerTest {
|
||||
|
||||
private class CapturingWriter : LogWriter() {
|
||||
val entries = mutableListOf<Triple<Severity, String, String>>()
|
||||
|
||||
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
|
||||
entries += Triple(severity, tag, message)
|
||||
}
|
||||
}
|
||||
|
||||
private val writer = CapturingWriter()
|
||||
private val writer = CapturingLogWriter()
|
||||
private val mqttLogger = KermitMqttLogger(Logger(loggerConfigInit(writer), tag = "Test"))
|
||||
|
||||
@Test
|
||||
@@ -50,8 +42,8 @@ class KermitMqttLoggerTest {
|
||||
)
|
||||
|
||||
assertEquals(1, writer.entries.size)
|
||||
assertEquals(Severity.Warn, writer.entries[0].first)
|
||||
assertEquals("MqttConnection", writer.entries[0].second)
|
||||
assertEquals(Severity.Warn, writer.entries[0].severity)
|
||||
assertEquals("MqttConnection", writer.entries[0].tag)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -63,7 +55,7 @@ class KermitMqttLoggerTest {
|
||||
throwable = IllegalStateException("bad state"),
|
||||
)
|
||||
|
||||
assertEquals(Severity.Error, writer.entries.single().first)
|
||||
assertEquals(Severity.Error, writer.entries.single().severity)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -71,7 +63,7 @@ class KermitMqttLoggerTest {
|
||||
// A bare message carries no stack or type to triage, so it must not count as an application error.
|
||||
mqttLogger.log(level = MqttLogLevel.ERROR, tag = "MqttClient", message = "boom", throwable = null)
|
||||
|
||||
assertEquals(Severity.Warn, writer.entries.single().first)
|
||||
assertEquals(Severity.Warn, writer.entries.single().severity)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -83,7 +75,7 @@ class KermitMqttLoggerTest {
|
||||
throwable = null,
|
||||
)
|
||||
|
||||
assertEquals(Severity.Warn, writer.entries.single().first)
|
||||
assertEquals(Severity.Warn, writer.entries.single().severity)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -96,7 +88,7 @@ class KermitMqttLoggerTest {
|
||||
throwable = null,
|
||||
)
|
||||
|
||||
assertEquals(Severity.Warn, writer.entries.single().first)
|
||||
assertEquals(Severity.Warn, writer.entries.single().severity)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -105,8 +97,8 @@ class KermitMqttLoggerTest {
|
||||
mqttLogger.log(MqttLogLevel.INFO, "MqttClient", "info", null)
|
||||
mqttLogger.log(MqttLogLevel.DEBUG, "MqttClient", "debug", null)
|
||||
|
||||
assertEquals(listOf(Severity.Warn, Severity.Info, Severity.Debug), writer.entries.map { it.first })
|
||||
assertEquals(listOf("MqttClient", "MqttClient", "MqttClient"), writer.entries.map { it.second })
|
||||
assertEquals(listOf(Severity.Warn, Severity.Info, Severity.Debug), writer.entries.map { it.severity })
|
||||
assertEquals(listOf("MqttClient", "MqttClient", "MqttClient"), writer.entries.map { it.tag })
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+3
-1
@@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.safeCatching
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
@@ -38,7 +39,8 @@ class JvmServiceDiscovery(private val dispatchers: CoroutineDispatchers) : Servi
|
||||
trySend(emptyList()) // Emit initial empty list so downstream combine() is not blocked
|
||||
|
||||
val bindAddress = findLanAddress() ?: InetAddress.getLocalHost()
|
||||
Logger.i { "JmDNS binding to ${bindAddress.hostAddress}" }
|
||||
val interfaceName = safeCatching { NetworkInterface.getByInetAddress(bindAddress)?.name }.getOrNull()
|
||||
Logger.i { "JmDNS binding to interface ${interfaceName ?: "unknown"}" }
|
||||
|
||||
val jmdns =
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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.core.testing
|
||||
|
||||
import co.touchlab.kermit.LogWriter
|
||||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
import co.touchlab.kermit.platformLogWriter
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A Kermit writer that records every line, for tests that assert what was or was not logged. Hand it to
|
||||
* `Logger(loggerConfigInit(writer))`, or [install] it on the global `Logger` and call [uninstall] in teardown.
|
||||
*/
|
||||
class CapturingLogWriter : LogWriter() {
|
||||
data class Entry(val severity: Severity, val tag: String, val message: String, val throwable: Throwable?)
|
||||
|
||||
private val recorded = mutableListOf<Entry>()
|
||||
|
||||
val entries: List<Entry>
|
||||
get() = recorded.toList()
|
||||
|
||||
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
|
||||
recorded += Entry(severity, tag, message, throwable)
|
||||
}
|
||||
|
||||
fun messages(severity: Severity? = null): List<String> =
|
||||
entries.filter { severity == null || it.severity == severity }.map { it.message }
|
||||
|
||||
/** Asserts that something was logged and that no message or attached throwable text contains any of [values]. */
|
||||
fun assertNotLogged(vararg values: String) {
|
||||
val text = entries.flatMap { listOfNotNull(it.message, it.throwable?.stackTraceToString()) }
|
||||
assertTrue(text.isNotEmpty(), "Expected something to be logged")
|
||||
for (value in values) {
|
||||
assertFalse(text.any { value in it }, "'$value' leaked into logs: $text")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Replaces the global `Logger` writers with a new capturing writer that keeps every severity. */
|
||||
fun install(): CapturingLogWriter = CapturingLogWriter().also {
|
||||
Logger.setLogWriters(it)
|
||||
Logger.setMinSeverity(Severity.Verbose)
|
||||
}
|
||||
|
||||
/** Restores the platform writer on the global `Logger`. */
|
||||
fun uninstall() {
|
||||
Logger.setLogWriters(platformLogWriter())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,8 +55,8 @@ actual fun rememberShowToastResource(): suspend (StringResource) -> Unit = { _ -
|
||||
|
||||
/** JVM stub — map opening is not available on Desktop. */
|
||||
@Composable
|
||||
actual fun rememberOpenMap(): (latitude: Double, longitude: Double, label: String) -> Unit = { lat, lon, label ->
|
||||
Logger.i { "Open map: $lat, $lon ($label)" }
|
||||
actual fun rememberOpenMap(): (latitude: Double, longitude: Double, label: String) -> Unit = { _, _, _ ->
|
||||
Logger.i { "Open map requested; not available on Desktop" }
|
||||
}
|
||||
|
||||
/** JVM stub — URL opening via Desktop browse API. */
|
||||
|
||||
@@ -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.desktop
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
import co.touchlab.kermit.platformLogWriter
|
||||
import org.meshtastic.core.common.log.InMemoryLogBuffer
|
||||
|
||||
/**
|
||||
* Sends Kermit output to the console and to [InMemoryLogBuffer], which the Debug screen views and exports. Release
|
||||
* builds keep Info and above, as Android release does; debug builds keep every level.
|
||||
*/
|
||||
internal fun installDesktopLogging(isDebug: Boolean) {
|
||||
Logger.setMinSeverity(if (isDebug) Severity.Verbose else Severity.Info)
|
||||
Logger.setLogWriters(listOf(platformLogWriter(), InMemoryLogBuffer))
|
||||
}
|
||||
@@ -52,7 +52,6 @@ import androidx.compose.ui.window.isTraySupported
|
||||
import androidx.compose.ui.window.rememberTrayState
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.platformLogWriter
|
||||
import coil3.ImageLoader
|
||||
import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.compose.setSingletonImageLoaderFactory
|
||||
@@ -78,7 +77,6 @@ import org.koin.plugin.module.dsl.startKoin
|
||||
import org.maplibre.compose.desktop.ProvideMapPresentationHost
|
||||
import org.maplibre.compose.desktop.rememberAwtComposeMapPresentationHost
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.common.log.InMemoryLogBuffer
|
||||
import org.meshtastic.core.common.state.LaunchOptions
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
@@ -163,8 +161,7 @@ fun main(args: Array<String>) {
|
||||
// No MapLibre.configure() call: the first map applies a default cache configuration process-wide.
|
||||
application(exitProcessOnExit = false) {
|
||||
val koinApp = remember {
|
||||
// Keep console output and also capture into the in-memory buffer the Debug screen views/exports.
|
||||
Logger.setLogWriters(listOf(platformLogWriter(), InMemoryLogBuffer))
|
||||
installDesktopLogging(isDebug = DesktopBuildConfig.IS_DEBUG)
|
||||
Logger.i { "Meshtastic Desktop — Starting" }
|
||||
startKoin<DesktopKoinApp> {}
|
||||
.also { app ->
|
||||
|
||||
+2
-1
@@ -24,6 +24,7 @@ import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.network.SerialTransport
|
||||
import org.meshtastic.core.network.radio.BaseRadioTransportFactory
|
||||
import org.meshtastic.core.network.radio.MockRadioTransport
|
||||
@@ -83,6 +84,6 @@ class DesktopRadioTransportFactory(
|
||||
)
|
||||
}
|
||||
|
||||
else -> error("Unsupported transport for address: $address")
|
||||
else -> error("Unsupported transport for address: ${address.anonymize()}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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.desktop
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
import co.touchlab.kermit.platformLogWriter
|
||||
import org.meshtastic.core.common.log.InMemoryLogBuffer
|
||||
import java.util.UUID
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopLoggingTest {
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
Logger.setLogWriters(platformLogWriter())
|
||||
Logger.setMinSeverity(Severity.Verbose)
|
||||
}
|
||||
|
||||
private fun marker(level: String) = "desktop-logging-test-$level-${UUID.randomUUID()}"
|
||||
|
||||
@Test
|
||||
fun `release build keeps Verbose and Debug lines out of the exportable buffer`() {
|
||||
installDesktopLogging(isDebug = false)
|
||||
val verbose = marker("verbose")
|
||||
val debug = marker("debug")
|
||||
val info = marker("info")
|
||||
|
||||
Logger.v { verbose }
|
||||
Logger.d { debug }
|
||||
Logger.i { info }
|
||||
|
||||
val buffer = InMemoryLogBuffer.snapshot()
|
||||
assertFalse(verbose in buffer, "Verbose line reached the release buffer")
|
||||
assertFalse(debug in buffer, "Debug line reached the release buffer")
|
||||
assertTrue(info in buffer, "Info line is missing from the release buffer")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `debug build keeps every level in the exportable buffer`() {
|
||||
installDesktopLogging(isDebug = true)
|
||||
val verbose = marker("verbose")
|
||||
val debug = marker("debug")
|
||||
|
||||
Logger.v { verbose }
|
||||
Logger.d { debug }
|
||||
|
||||
val buffer = InMemoryLogBuffer.snapshot()
|
||||
assertTrue(verbose in buffer, "Verbose line is missing from the debug buffer")
|
||||
assertTrue(debug in buffer, "Debug line is missing from the debug buffer")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Debug Logs
|
||||
parent: User Guide
|
||||
nav_order: 22
|
||||
last_updated: 2026-08-30
|
||||
last_updated: 2026-09-28
|
||||
description: View and export the app's own debug logs from inside the app, and attach a capture to a GitHub issue to help diagnose bugs — no adb required.
|
||||
aliases:
|
||||
- debug-logs
|
||||
@@ -48,7 +48,7 @@ Attach that file to your GitHub issue.
|
||||
|
||||
## Desktop
|
||||
|
||||
The desktop app has no system logcat, so the **App logs** tab shows the app's own captured log output instead. Search, filtering, and export work the same way.
|
||||
The desktop app has no system logcat, so the **App logs** tab shows the app's own captured log output instead. Search, filtering, and export work the same way. Release builds capture Info, Warn, and Error lines; Verbose and Debug lines appear only in development builds.
|
||||
|
||||
## Related Topics
|
||||
|
||||
|
||||
+12
-8
@@ -117,7 +117,7 @@ class NymeaWifiService(
|
||||
.onEach { bytes ->
|
||||
val message = reassembler.feed(bytes)
|
||||
if (message != null) {
|
||||
Logger.d { "$TAG: ← $message" }
|
||||
Logger.d { "$TAG: ← response (${message.length} chars)" }
|
||||
responseChannel.trySend(message)
|
||||
}
|
||||
if (!subscribed.isCompleted) subscribed.complete(Unit)
|
||||
@@ -145,14 +145,14 @@ class NymeaWifiService(
|
||||
*/
|
||||
suspend fun scanNetworks(): Result<List<WifiNetwork>> = safeCatching {
|
||||
// Trigger scan
|
||||
sendCommand(NymeaJson.encodeToString(NymeaSimpleCommand(CMD_SCAN)))
|
||||
sendCommand(CMD_SCAN, NymeaJson.encodeToString(NymeaSimpleCommand(CMD_SCAN)))
|
||||
val scanAck = NymeaJson.decodeFromString<NymeaResponse>(waitForResponse())
|
||||
if (scanAck.responseCode != RESPONSE_SUCCESS) {
|
||||
error("Scan command failed: ${nymeaErrorMessage(scanAck.responseCode)}")
|
||||
}
|
||||
|
||||
// Fetch results
|
||||
sendCommand(NymeaJson.encodeToString(NymeaSimpleCommand(CMD_GET_NETWORKS)))
|
||||
sendCommand(CMD_GET_NETWORKS, NymeaJson.encodeToString(NymeaSimpleCommand(CMD_GET_NETWORKS)))
|
||||
val networksResponse = NymeaJson.decodeFromString<NymeaNetworksResponse>(waitForResponse())
|
||||
if (networksResponse.responseCode != RESPONSE_SUCCESS) {
|
||||
error("GetNetworks failed: ${nymeaErrorMessage(networksResponse.responseCode)}")
|
||||
@@ -186,7 +186,7 @@ class NymeaWifiService(
|
||||
)
|
||||
|
||||
return safeCatching {
|
||||
sendCommand(json)
|
||||
sendCommand(cmd, json)
|
||||
val response = NymeaJson.decodeFromString<NymeaResponse>(waitForResponse())
|
||||
if (response.responseCode == RESPONSE_SUCCESS) {
|
||||
val ipAddress =
|
||||
@@ -224,9 +224,13 @@ class NymeaWifiService(
|
||||
|
||||
// region Internal helpers
|
||||
|
||||
/** Encode [json] into ≤20-byte packets and write each one WITH_RESPONSE to the commander characteristic. */
|
||||
private suspend fun sendCommand(json: String) {
|
||||
Logger.d { "$TAG: → $json" }
|
||||
/**
|
||||
* Encode [json] into ≤20-byte packets and write each one WITH_RESPONSE to the commander characteristic. Only the
|
||||
* [command] code is logged: a Connect payload carries the WiFi password, and even its length gives away the SSID
|
||||
* and password lengths.
|
||||
*/
|
||||
private suspend fun sendCommand(command: Int, json: String) {
|
||||
Logger.d { "$TAG: → command=$command" }
|
||||
val packets = NymeaPacketCodec.encode(json)
|
||||
bleConnection.profile(WIRELESS_SERVICE_UUID) { service ->
|
||||
for (packet in packets) {
|
||||
@@ -245,7 +249,7 @@ class NymeaWifiService(
|
||||
* Uses a short timeout because this is an optional enrichment for UX, not a provisioning success criterion.
|
||||
*/
|
||||
private suspend fun fetchConnectionIpAddress(): String? = safeCatching {
|
||||
sendCommand(NymeaJson.encodeToString(NymeaSimpleCommand(CMD_GET_CONNECTION)))
|
||||
sendCommand(CMD_GET_CONNECTION, NymeaJson.encodeToString(NymeaSimpleCommand(CMD_GET_CONNECTION)))
|
||||
val response =
|
||||
NymeaJson.decodeFromString<NymeaResponse>(waitForResponse(timeout = CONNECTION_INFO_TIMEOUT))
|
||||
if (response.responseCode == RESPONSE_SUCCESS) {
|
||||
|
||||
+25
@@ -22,6 +22,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.ble.BleWriteType
|
||||
import org.meshtastic.core.testing.CapturingLogWriter
|
||||
import org.meshtastic.core.testing.FakeBleConnection
|
||||
import org.meshtastic.core.testing.FakeBleConnectionFactory
|
||||
import org.meshtastic.core.testing.FakeBleDevice
|
||||
@@ -29,6 +30,7 @@ import org.meshtastic.core.testing.FakeBleScanner
|
||||
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.COMMANDER_RESPONSE_UUID
|
||||
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.WIRELESS_COMMANDER_UUID
|
||||
import org.meshtastic.feature.wifiprovision.model.ProvisionResult
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
@@ -43,6 +45,11 @@ class NymeaWifiServiceTest {
|
||||
|
||||
private val address = "AA:BB:CC:DD:EE:FF"
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
CapturingLogWriter.uninstall()
|
||||
}
|
||||
|
||||
private fun createService(
|
||||
scanner: FakeBleScanner = FakeBleScanner(),
|
||||
connection: FakeBleConnection = FakeBleConnection(),
|
||||
@@ -293,6 +300,24 @@ class NymeaWifiServiceTest {
|
||||
assertTrue(writes.contains("\"c\":2"), "Should send CMD_CONNECT_HIDDEN (2)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `provision never logs the WiFi credentials or the device response payload`() = runTest {
|
||||
val connection = FakeBleConnection()
|
||||
val (service, scanner) = createService(connection = connection)
|
||||
connectService(service, scanner)
|
||||
val logs = CapturingLogWriter.install()
|
||||
|
||||
emitResponse(connection, """{"c":1,"r":0,"p":{"i":"10.77.88.99"}}""")
|
||||
val result = service.provision("SecretHomeNet", "hunter2-wifi-pass")
|
||||
|
||||
assertIs<ProvisionResult.Success>(result)
|
||||
assertTrue(
|
||||
logs.messages().any { it.endsWith("command=1") },
|
||||
"Command send should be logged with nothing after the code: ${logs.messages()}",
|
||||
)
|
||||
logs.assertNotLogged("hunter2-wifi-pass", "SecretHomeNet", "10.77.88.99")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `provision returns Failure on exception`() = runTest {
|
||||
// Create a service with a connection that will fail writes after connecting
|
||||
|
||||
Reference in new issue
Block a user