fix(resources): add resourcePrefix to KMP + widget modules, rename prefixed resources (#5111)

This commit is contained in:
James Rich 2026-04-13 13:25:23 -05:00 committed by GitHub
parent 8a06157ff4
commit b13f9bf989
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 308 additions and 7 deletions

View file

@ -288,7 +288,7 @@
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/local_stats_widget_info" />
android:resource="@xml/widget_local_stats_info" />
</receiver>
<!-- allow for plugin discovery -->

View file

@ -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)
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<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
}
},
)
}
}

View file

@ -25,7 +25,10 @@ kotlin {
@Suppress("UnstableApiUsage")
android {
androidResources.enable = true
androidResources {
enable = true
resourcePrefix = "meshtastic_"
}
withHostTest { isIncludeAndroidResources = true }
}

View file

@ -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()

View file

@ -23,6 +23,7 @@ plugins {
android {
namespace = "org.meshtastic.feature.widget"
resourcePrefix = "widget_"
defaultConfig { minSdk = 26 }
}

View file

@ -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<RefreshLocalStatsAction>(),
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),
)