diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 43468c69d..f7d2ce900 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -288,7 +288,7 @@ + android:resource="@xml/widget_local_stats_info" /> diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 903dde119..7d46cc831 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -24,7 +24,11 @@ plugins { kotlin { jvm() - android { namespace = "org.meshtastic.core.datastore" } + android { + namespace = "org.meshtastic.core.datastore" + androidResources.enable = false + withHostTest {} + } sourceSets { commonMain.dependencies { @@ -36,5 +40,11 @@ kotlin { implementation(libs.kotlinx.serialization.json) implementation(libs.kermit) } + + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.okio) + } } } diff --git a/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt b/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt new file mode 100644 index 000000000..3acd29cb9 --- /dev/null +++ b/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2025-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 . + */ +package org.meshtastic.core.datastore + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.Flow +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.model.RecentAddress +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +class RecentAddressesDataSourceTest { + private lateinit var tmpDir: Path + private lateinit var dataSource: RecentAddressesDataSource + + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + @BeforeTest + fun setup() { + tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "recentAddressesTest-${Uuid.random()}" + FileSystem.SYSTEM.createDirectories(tmpDir) + val dataStore = + PreferenceDataStoreFactory.createWithPath( + scope = testScope, + produceFile = { tmpDir / "test.preferences_pb" }, + ) + dataSource = RecentAddressesDataSource(dataStore) + } + + @AfterTest + fun tearDown() { + FileSystem.SYSTEM.deleteRecursively(tmpDir) + } + + // ---- recentAddresses flow ---- + + @Test + fun `recentAddresses emits empty list when no data stored`() = testScope.runTest { + val result = dataSource.recentAddresses.first() + assertTrue(result.isEmpty()) + } + + @Test + fun `setRecentAddresses persists and emits the list`() = testScope.runTest { + val addresses = + listOf( + RecentAddress(address = "192.168.1.1", name = "Home"), + RecentAddress(address = "10.0.0.1", name = "Office"), + ) + dataSource.setRecentAddresses(addresses) + + val result = dataSource.recentAddresses.first() + assertEquals(addresses, result) + } + + @Test + fun `setRecentAddresses overwrites previous value`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.2.3.4", "Old"))) + dataSource.setRecentAddresses(listOf(RecentAddress("5.6.7.8", "New"))) + + val result = dataSource.recentAddresses.first() + assertEquals(1, result.size) + assertEquals("5.6.7.8", result[0].address) + } + + // ---- add() LRU behaviour ---- + + @Test + fun `add to empty list stores single entry`() = testScope.runTest { + dataSource.add(RecentAddress("192.168.0.1", "Router")) + + val result = dataSource.recentAddresses.first() + assertEquals(1, result.size) + assertEquals("192.168.0.1", result[0].address) + } + + @Test + fun `add prepends new address to front`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "Existing"))) + dataSource.add(RecentAddress("2.2.2.2", "New")) + + val result = dataSource.recentAddresses.first() + assertEquals("2.2.2.2", result[0].address) + assertEquals("1.1.1.1", result[1].address) + } + + @Test + fun `add deduplicates by address moving existing entry to front with updated name`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "First"), RecentAddress("2.2.2.2", "Second"))) + dataSource.add(RecentAddress("2.2.2.2", "Second-updated")) + + val result = dataSource.recentAddresses.first() + assertEquals(2, result.size) + assertEquals("2.2.2.2", result[0].address) + assertEquals("Second-updated", result[0].name) + assertEquals("1.1.1.1", result[1].address) + } + + @Test + fun `add enforces CACHE_CAPACITY of 3 evicting oldest entry`() = testScope.runTest { + dataSource.setRecentAddresses( + listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B"), RecentAddress("3.3.3.3", "C")), + ) + dataSource.add(RecentAddress("4.4.4.4", "D")) + + val result = dataSource.recentAddresses.first() + assertEquals(3, result.size) + assertEquals("4.4.4.4", result[0].address) + assertEquals("1.1.1.1", result[1].address) + assertEquals("2.2.2.2", result[2].address) + assertFalse(result.any { it.address == "3.3.3.3" }) + } + + @Test + fun `add re-adding the same address at front keeps capacity`() = testScope.runTest { + dataSource.setRecentAddresses( + listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B"), RecentAddress("3.3.3.3", "C")), + ) + dataSource.add(RecentAddress("1.1.1.1", "A")) + + val result = dataSource.recentAddresses.first() + assertEquals(3, result.size) + assertEquals("1.1.1.1", result[0].address) + } + + // ---- remove() ---- + + @Test + fun `remove deletes the matching address`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B"))) + dataSource.remove("1.1.1.1") + + val result = dataSource.recentAddresses.first() + assertEquals(1, result.size) + assertEquals("2.2.2.2", result[0].address) + } + + @Test + fun `remove on unknown address is a no-op`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A"))) + dataSource.remove("9.9.9.9") + + val result = dataSource.recentAddresses.first() + assertEquals(1, result.size) + } + + @Test + fun `remove last address yields empty list`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A"))) + dataSource.remove("1.1.1.1") + + assertTrue(dataSource.recentAddresses.first().isEmpty()) + } + + // ---- legacy JSON parsing (via LegacyParsingHarness) ---- + + @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() + + assertEquals(2, result.size) + assertEquals("192.168.1.100", result[0].address) + assertEquals("NodeA", result[0].name) + assertEquals("192.168.1.101", result[1].address) + assertEquals("NodeB", result[1].name) + } + + @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() + + assertEquals(2, result.size) + assertEquals("192.168.1.50", result[0].address) + assertEquals("Meshtastic", result[0].name) + assertEquals("10.0.0.2", result[1].address) + assertEquals("Meshtastic", result[1].name) + } + + @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() + + assertEquals(1, result.size) + assertEquals("1.2.3.4", result[0].address) + } + + @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() + + assertEquals(1, result.size) + assertEquals("5.6.7.8", result[0].address) + } + + @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() + + assertEquals(1, result.size) + assertEquals("1.2.3.4", result[0].address) + } + + @Test + fun `legacy mixed array handles all element types`() = 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() + + 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> = 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 + } + }, + ) + } +} diff --git a/core/resources/build.gradle.kts b/core/resources/build.gradle.kts index a1ba8fd63..966ab949a 100644 --- a/core/resources/build.gradle.kts +++ b/core/resources/build.gradle.kts @@ -25,7 +25,10 @@ kotlin { @Suppress("UnstableApiUsage") android { - androidResources.enable = true + androidResources { + enable = true + resourcePrefix = "meshtastic_" + } withHostTest { isIncludeAndroidResources = true } } diff --git a/core/resources/src/androidMain/res/raw/alert.mp3 b/core/resources/src/androidMain/res/raw/meshtastic_alert.mp3 similarity index 100% rename from core/resources/src/androidMain/res/raw/alert.mp3 rename to core/resources/src/androidMain/res/raw/meshtastic_alert.mp3 diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt index cff4ec041..211e3b9c4 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt @@ -267,7 +267,8 @@ class MeshServiceNotificationsImpl( enableLights(true) enableVibration(true) setBypassDnd(true) - val alertSoundUri = "${SCHEME_ANDROID_RESOURCE}://${context.packageName}/${raw.alert}".toUri() + val alertSoundUri = + "${SCHEME_ANDROID_RESOURCE}://${context.packageName}/${raw.meshtastic_alert}".toUri() setSound( alertSoundUri, AudioAttributes.Builder() diff --git a/feature/widget/build.gradle.kts b/feature/widget/build.gradle.kts index 8d2045469..3054da6df 100644 --- a/feature/widget/build.gradle.kts +++ b/feature/widget/build.gradle.kts @@ -23,6 +23,7 @@ plugins { android { namespace = "org.meshtastic.feature.widget" + resourcePrefix = "widget_" defaultConfig { minSdk = 26 } } diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt index 6f988f2db..099b24cc3 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt @@ -132,11 +132,11 @@ class LocalStatsWidget : Scaffold( titleBar = { TitleBar( - startIcon = ImageProvider(R.drawable.app_icon), + startIcon = ImageProvider(R.drawable.widget_app_icon), title = stringResource(Res.string.meshtastic_app_name), actions = { CircleIconButton( - imageProvider = ImageProvider(R.drawable.ic_refresh), + imageProvider = ImageProvider(R.drawable.widget_ic_refresh), contentDescription = stringResource(Res.string.refresh), onClick = actionRunCallback(), backgroundColor = null, @@ -297,7 +297,7 @@ class LocalStatsWidget : CircularProgressIndicator(modifier = GlanceModifier.size(24.dp)) } else { Image( - provider = ImageProvider(R.drawable.app_icon), + provider = ImageProvider(R.drawable.widget_app_icon), contentDescription = null, modifier = GlanceModifier.size(32.dp), ) diff --git a/feature/widget/src/main/res/drawable/app_icon.xml b/feature/widget/src/main/res/drawable/widget_app_icon.xml similarity index 100% rename from feature/widget/src/main/res/drawable/app_icon.xml rename to feature/widget/src/main/res/drawable/widget_app_icon.xml diff --git a/feature/widget/src/main/res/drawable/ic_refresh.xml b/feature/widget/src/main/res/drawable/widget_ic_refresh.xml similarity index 100% rename from feature/widget/src/main/res/drawable/ic_refresh.xml rename to feature/widget/src/main/res/drawable/widget_ic_refresh.xml diff --git a/feature/widget/src/main/res/xml/local_stats_widget_info.xml b/feature/widget/src/main/res/xml/widget_local_stats_info.xml similarity index 100% rename from feature/widget/src/main/res/xml/local_stats_widget_info.xml rename to feature/widget/src/main/res/xml/widget_local_stats_info.xml