From b13f9bf9893e865adfa939a144854e8076af60ea Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 13 Apr 2026 13:25:23 -0500
Subject: [PATCH] fix(resources): add resourcePrefix to KMP + widget modules,
rename prefixed resources (#5111)
---
app/src/main/AndroidManifest.xml | 2 +-
core/datastore/build.gradle.kts | 12 +-
.../RecentAddressesDataSourceTest.kt | 286 ++++++++++++++++++
core/resources/build.gradle.kts | 5 +-
.../raw/{alert.mp3 => meshtastic_alert.mp3} | Bin
.../service/MeshServiceNotificationsImpl.kt | 3 +-
feature/widget/build.gradle.kts | 1 +
.../feature/widget/LocalStatsWidget.kt | 6 +-
.../{app_icon.xml => widget_app_icon.xml} | 0
.../{ic_refresh.xml => widget_ic_refresh.xml} | 0
...t_info.xml => widget_local_stats_info.xml} | 0
11 files changed, 308 insertions(+), 7 deletions(-)
create mode 100644 core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt
rename core/resources/src/androidMain/res/raw/{alert.mp3 => meshtastic_alert.mp3} (100%)
rename feature/widget/src/main/res/drawable/{app_icon.xml => widget_app_icon.xml} (100%)
rename feature/widget/src/main/res/drawable/{ic_refresh.xml => widget_ic_refresh.xml} (100%)
rename feature/widget/src/main/res/xml/{local_stats_widget_info.xml => widget_local_stats_info.xml} (100%)
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